[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

bsky posts on profile (#61)

* both there

* deduplicate crossposts

* sort

* achei melhorzin de bao assim

* lint

* unify refresh to profile refresh

* que deus elimine todos aqueles que um dia tiveram o desprazer de encostar nessa codeabse

authored by

Davi Rodrigues and committed by
GitHub
25f044e4 fdd26fbe

+383 -297
+24 -7
lib/src/core/network/atproto/data/models/feed_models.dart
··· 143 143 @JsonSerializable(explicitToJson: true) 144 144 const factory ReplyRefPostReference.post({required PostView post}) = ReplyRefPostReferencePost; 145 145 146 + @FreezedUnionValue('app.bsky.feed.defs#postView') 147 + @JsonSerializable(explicitToJson: true) 148 + const factory ReplyRefPostReference.bskyPost({required PostView post}) = ReplyRefPostReferenceBskyPost; 149 + 146 150 @FreezedUnionValue('so.sprk.feed.defs#notFoundPost') 147 151 @JsonSerializable(explicitToJson: true) 148 152 const factory ReplyRefPostReference.notFoundPost({@AtUriConverter() required AtUri uri, required bool notFound}) = ··· 219 223 220 224 bool get isSprk => RegExp(r'^at://[^/]+/so\.sprk\.feed\.post/[^/]+$').hasMatch(uri.toString()); 221 225 222 - /// Resolves AT Protocol blob URLs to HTTP URLs for display 226 + /// Resolves AT Protocol blob URLs to HTTP URLs for display 223 227 String _resolveAtUriToHttpUrl(AtUri atUri, {bool isFullsize = false}) { 224 228 final uriString = atUri.toString(); 225 229 ··· 386 390 387 391 @FreezedUnionValue('app.bsky.embed.record#view') 388 392 @JsonSerializable(explicitToJson: true) 389 - const factory EmbedView.bskyRecord({required StrongRef record, required String cid}) = EmbedViewBskyRecord; 393 + const factory EmbedView.bskyRecord({required EmbedViewBskyRecordViewRecord record}) = EmbedViewBskyRecord; 394 + 395 + @FreezedUnionValue('app.bsky.embed.record#viewRecord') 396 + @JsonSerializable(explicitToJson: true) 397 + const factory EmbedView.bskyRecordViewRecord({ 398 + @AtUriConverter() required AtUri uri, 399 + required String cid, 400 + required ProfileViewBasic author, 401 + required dynamic value, 402 + required DateTime indexedAt, 403 + @Default([]) List<Label> labels, 404 + int? replyCount, 405 + int? repostCount, 406 + int? likeCount, 407 + int? quoteCount, 408 + @Default([]) List<EmbedView> embeds, 409 + }) = EmbedViewBskyRecordViewRecord; 390 410 391 411 @FreezedUnionValue('app.bsky.embed.recordWithMedia#view') 392 412 @JsonSerializable(explicitToJson: true) 393 - const factory EmbedView.bskyRecordWithMedia({required StrongRef record, required EmbedView media, required String cid}) = 413 + const factory EmbedView.bskyRecordWithMedia({required EmbedViewBskyRecord record, required EmbedView media}) = 394 414 EmbedViewBskyRecordWithMedia; 395 415 396 416 @FreezedUnionValue('app.bsky.embed.external#view') 397 417 @JsonSerializable(explicitToJson: true) 398 - const factory EmbedView.bskyExternal({ 399 - required EmbedViewExternal external, 400 - required String cid, 401 - }) = EmbedViewBskyExternal; 418 + const factory EmbedView.bskyExternal({required EmbedViewExternal external, required String cid}) = EmbedViewBskyExternal; 402 419 403 420 factory EmbedView.fromJson(Map<String, dynamic> json) => _$EmbedViewFromJson(json); 404 421 }
+3
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 21 21 /// [actorUri] The URI of the author 22 22 /// [limit] The number of items to return (default 20) 23 23 /// [cursor] Pagination cursor for the next set of results 24 + /// [videosOnly] Whether to only fetch posts with videos 25 + /// [bluesky] Whether to fetch from Bluesky API instead of Spark 24 26 Future<({List<FeedViewPost> posts, String? cursor})> getAuthorFeed( 25 27 AtUri actorUri, { 26 28 int limit = 20, 27 29 String? cursor, 28 30 bool videosOnly = false, 31 + bool bluesky = false, 29 32 }); 30 33 31 34 /// Like a post
+107 -70
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 24 24 _logger.v('FeedRepository initialized'); 25 25 } 26 26 27 + List<T> _parseAndFilterPosts<T>({ 28 + required List<dynamic> rawPosts, 29 + required T Function(Map<String, dynamic>) fromJson, 30 + required PostView Function(T) getPostView, 31 + required String source, 32 + }) { 33 + final posts = <T>[]; 34 + for (final rawPost in rawPosts) { 35 + try { 36 + final postData = rawPost is Map<String, dynamic> ? rawPost : rawPost.toJson(); 37 + if (postData['reply'] != null) { 38 + continue; 39 + } 40 + final parsedPost = fromJson(postData); 41 + final postView = getPostView(parsedPost); 42 + 43 + if (postView.hasSupportedMedia) { 44 + posts.add(parsedPost); 45 + } else { 46 + _logger.d('Filtered out $source post with unsupported embed type: ${postView.uri}'); 47 + } 48 + } catch (e) { 49 + _logger.w('Failed to parse $source post, skipping: $e'); 50 + } 51 + } 52 + return posts; 53 + } 54 + 27 55 @override 28 56 Future<FeedSkeleton> getFeedSkeleton(Feed feed, {int? limit, String? cursor}) async { 29 57 _logger.d('Getting feed skeleton for feed: $feed, limit: $limit, cursor: $cursor'); ··· 64 92 Future<List<PostView>> getPosts(List<AtUri> uris, {bool bluesky = false}) async { 65 93 _logger.d('Getting posts for URIs: ${uris.length} URIs'); 66 94 if (bluesky) { 67 - final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 68 - final posts = await bluesky.feed.getPosts(uris: uris.map((uri) => AtUri.parse(uri.toString())).toList()); 69 - final filteredPosts = <PostView>[]; 70 - for (final post in posts.data.posts) { 71 - try { 72 - final postView = PostView.fromJson(post.toJson()); 73 - // Only add posts that have supported media (video or images) 74 - if (postView.hasSupportedMedia) { 75 - filteredPosts.add(postView); 76 - } else { 77 - _logger.d('Filtered out bluesky post with unsupported embed type: ${postView.uri}'); 78 - } 79 - } catch (e) { 80 - _logger.w('Failed to parse bluesky post, skipping: $e'); 81 - // Skip posts that fail to parse instead of crashing 82 - } 83 - } 84 - return filteredPosts; 95 + _logger.d('Getting posts on bluesky API for: ${uris.length} URIs'); 96 + final blueskyClient = bsky.Bluesky.fromSession(_client.authRepository.session!); 97 + final posts = await blueskyClient.feed.getPosts(uris: uris.map((uri) => AtUri.parse(uri.toString())).toList()); 98 + return _parseAndFilterPosts<PostView>( 99 + rawPosts: posts.data.posts, 100 + fromJson: PostView.fromJson, 101 + getPostView: (post) => post, 102 + source: 'bsky', 103 + ); 85 104 } 86 105 return _client.executeWithRetry(() async { 87 106 if (!_client.authRepository.isAuthenticated) { ··· 101 120 headers: {'atproto-proxy': _client.sprkDid}, 102 121 to: (jsonMap) { 103 122 final posts = jsonMap['posts'] as List<dynamic>; 104 - final postViews = <PostView>[]; 105 - for (final post in posts) { 106 - try { 107 - final postView = PostView.fromJson(post); 108 - postViews.add(postView); 109 - } catch (e) { 110 - _logger.w('Failed to parse post, skipping: $e'); 111 - // Skip posts that fail to parse instead of crashing 112 - } 113 - } 114 - return postViews; 123 + return _parseAndFilterPosts<PostView>( 124 + rawPosts: posts, 125 + fromJson: PostView.fromJson, 126 + getPostView: (post) => post, 127 + source: 'sprk', 128 + ); 115 129 }, 116 130 adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 117 131 ); ··· 127 141 int limit = 20, 128 142 String? cursor, 129 143 bool videosOnly = false, 144 + bool bluesky = false, 145 + }) async { 146 + _logger.d('Getting author feed for actor: $actorUri, limit: $limit, cursor: $cursor, bluesky: $bluesky'); 147 + 148 + if (bluesky) { 149 + return _getAuthorFeedFromBluesky(actorUri, limit: limit, cursor: cursor, videosOnly: videosOnly); 150 + } 151 + 152 + return _getAuthorFeedFromSpark(actorUri, limit: limit, cursor: cursor, videosOnly: videosOnly); 153 + } 154 + 155 + /// Get author feed from Spark API with fallback to Bluesky 156 + Future<({List<FeedViewPost> posts, String? cursor})> _getAuthorFeedFromSpark( 157 + AtUri actorUri, { 158 + required int limit, 159 + required String? cursor, 160 + required bool videosOnly, 130 161 }) async { 131 - _logger.d('Getting author feed for actor: $actorUri, limit: $limit, cursor: $cursor'); 132 162 return _client.executeWithRetry(() async { 133 163 if (!_client.authRepository.isAuthenticated) { 134 164 _logger.w('Not authenticated'); ··· 152 182 if (cursor != null) { 153 183 parameters['cursor'] = cursor; 154 184 } 185 + 155 186 try { 156 187 final result = await atproto.get( 157 188 NSID.parse('so.sprk.feed.getAuthorFeed'), 158 189 parameters: parameters, 159 190 headers: {'atproto-proxy': _client.sprkDid}, 160 191 to: (jsonMap) { 161 - final feedPosts = <FeedViewPost>[]; 162 192 final rawFeed = jsonMap['feed'] as List<dynamic>; 163 - for (final postData in rawFeed) { 164 - try { 165 - final feedViewPost = FeedViewPost.fromJson(postData); 166 - // Only add posts that have supported media (video or images) 167 - if (feedViewPost.post.hasSupportedMedia) { 168 - feedPosts.add(feedViewPost); 169 - } else { 170 - _logger.d('Filtered out author feed post with unsupported embed type: ${feedViewPost.post.uri}'); 171 - } 172 - } catch (e) { 173 - _logger.w('Failed to parse author feed post, skipping: $e'); 174 - // Skip posts that fail to parse instead of crashing 175 - } 176 - } 193 + final feedPosts = _parseAndFilterPosts<FeedViewPost>( 194 + rawPosts: rawFeed, 195 + fromJson: FeedViewPost.fromJson, 196 + getPostView: (feedViewPost) => feedViewPost.post, 197 + source: 'sprk author feed', 198 + ); 177 199 return (posts: feedPosts, cursor: jsonMap['cursor'] as String?); 178 200 }, 179 201 adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 180 202 ); 181 - _logger.d('Author feed retrieved successfully'); 203 + _logger.d('Author feed retrieved successfully from Sprk'); 182 204 return result.data; 183 205 } catch (e) { 184 - _logger.e('Error getting author feed from spark. Trying bluesky...', error: e); 206 + _logger.e('Error getting author feed from Sprk. Trying Bsky...', error: e); 207 + return _getAuthorFeedFromBluesky(actorUri, limit: limit, cursor: cursor, videosOnly: videosOnly); 208 + } 209 + }); 210 + } 211 + 212 + /// Get author feed directly from Bluesky API 213 + Future<({List<FeedViewPost> posts, String? cursor})> _getAuthorFeedFromBluesky( 214 + AtUri actorUri, { 215 + required int limit, 216 + required String? cursor, 217 + required bool videosOnly, 218 + }) async { 219 + return _client.executeWithRetry(() async { 220 + if (!_client.authRepository.isAuthenticated) { 221 + _logger.w('Not authenticated'); 222 + throw Exception('Not authenticated'); 223 + } 224 + 225 + try { 185 226 final resultBsky = await bsky.Bluesky.fromSession(_client.authRepository.session!).feed.getAuthorFeed( 186 - actor: actorUri.toString(), 227 + actor: actorUri.hostname, 187 228 limit: limit, 188 229 cursor: cursor, 189 230 filter: videosOnly ? bsky.FeedFilter.postsWithVideo : bsky.FeedFilter.postsWithMedia, 190 231 ); 191 - _logger.d('Author feed retrieved successfully'); 192 - final filteredPosts = <FeedViewPost>[]; 193 - for (final postData in resultBsky.data.feed) { 194 - try { 195 - final feedViewPost = FeedViewPost.fromJson(postData.toJson()); 196 - // Only add posts that have supported media (video or images) 197 - if (feedViewPost.post.hasSupportedMedia) { 198 - filteredPosts.add(feedViewPost); 199 - } else { 200 - _logger.d('Filtered out bluesky author feed post with unsupported embed type: ${feedViewPost.post.uri}'); 201 - } 202 - } catch (e) { 203 - _logger.w('Failed to parse bluesky author feed post, skipping: $e'); 204 - // Skip posts that fail to parse instead of crashing 205 - } 206 - } 232 + 233 + final filteredPosts = _parseAndFilterPosts<FeedViewPost>( 234 + rawPosts: resultBsky.data.feed.map((post) => post.toJson()).toList(), 235 + fromJson: FeedViewPost.fromJson, 236 + getPostView: (feedViewPost) => feedViewPost.post, 237 + source: 'bsky author feed', 238 + ); 239 + 240 + _logger.d('Author feed retrieved successfully from Bsky'); 207 241 return (posts: filteredPosts, cursor: resultBsky.data.cursor); 242 + } catch (e) { 243 + _logger.e('Error getting author feed from Bsky', error: e); 244 + rethrow; 208 245 } 209 246 }); 210 247 } ··· 295 332 } 296 333 297 334 // Create the correct record JSON depending on the target platform. 298 - final bool isSpark = parentUri.toString().contains('sprk'); 335 + final bool isSprk = parentUri.toString().contains('sprk'); 299 336 300 337 final Map<String, dynamic> recordJson; 301 338 final NSID collection; 302 339 303 - if (isSpark) { 304 - // Spark comment 305 - final PostRecord sparkRecord = PostRecord( 340 + if (isSprk) { 341 + // Sprk comment 342 + final PostRecord sprkRecord = PostRecord( 306 343 text: text, 307 344 reply: RecordReplyRef( 308 345 root: StrongRef(uri: effectiveRootUri, cid: effectiveRootCid), ··· 311 348 createdAt: DateTime.now(), 312 349 embed: embedJson != null ? Embed.fromJson(embedJson) : null, 313 350 ); 314 - recordJson = sparkRecord.toJson(); 351 + recordJson = sprkRecord.toJson(); 315 352 collection = NSID.parse('so.sprk.feed.post'); 316 353 } else { 317 354 // Bluesky comment ··· 360 397 if (_client.authRepository.atproto case final atproto?) { 361 398 final List<Image> uploadedImageMaps = await uploadImages(imageFiles, altTexts); 362 399 363 - // Create Spark post first 400 + // Create Sprk post first 364 401 final record = PostRecord( 365 402 text: text, 366 403 embed: EmbedImage(images: uploadedImageMaps),
+2 -9
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 48 48 // If cache fails, fetch from network 49 49 final feedRepository = GetIt.instance<SprkRepository>().feed; 50 50 final uri = AtUri.parse(widget.postUri); 51 - 52 - List<PostView> networkPost; 53 - try { 54 - // Try Spark network first 55 - networkPost = await feedRepository.getPosts([uri], bluesky: false); 56 - } catch (e) { 57 - // Fallback to Bluesky network 58 - networkPost = await feedRepository.getPosts([uri], bluesky: true); 59 - } 51 + final isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 52 + final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 60 53 61 54 if (networkPost.isEmpty) { 62 55 throw Exception('Post not found');
+143 -43
lib/src/features/profile/providers/profile_feed_provider.dart
··· 9 9 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 10 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 11 import 'package:sparksocial/src/features/profile/providers/profile_feed_state.dart'; 12 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 12 13 13 14 part 'profile_feed_provider.g.dart'; 15 + 16 + typedef _FeedSourceFetcher = Future<({List<FeedViewPost> posts, String? cursor})> Function(String? cursor); 14 17 15 18 @riverpod 16 19 class ProfileFeed extends _$ProfileFeed { ··· 25 28 _sqlCache = GetIt.instance<SQLCacheInterface>(); 26 29 _logger = GetIt.instance<LogService>().getLogger('ProfileFeed ${profileUri.toString()}'); 27 30 28 - // Load initial data instead of returning empty state 29 31 try { 30 - final result = await _feedRepository.getAuthorFeed( 31 - profileUri, 32 - limit: ProfileFeedState.fetchLimit, 33 - cursor: null, 32 + final result = await _loadUnifiedFeed( 33 + profileUri: profileUri, 34 + sparkCursor: null, 35 + blueskyCursor: null, 34 36 videosOnly: videosOnly, 35 37 ); 38 + _logger.d( 39 + 'Loaded initial unified feed: ${result.allPosts.length} total posts, ${result.loadedPosts.length} filtered posts', 40 + ); 41 + return result; 42 + } catch (e) { 43 + _logger.e('Error loading initial posts: $e'); 44 + rethrow; 45 + } 46 + } 36 47 37 - if (!videosOnly) { 38 - result.posts.removeWhere((post) => post.post.videoUrl.isNotEmpty); 48 + Future<ProfileFeedState> _loadUnifiedFeed({ 49 + required AtUri profileUri, 50 + required String? sparkCursor, 51 + required String? blueskyCursor, 52 + required bool videosOnly, 53 + ProfileFeedState? currentState, 54 + }) async { 55 + final postSources = Map<AtUri, String>.from(currentState?.postSources ?? {}); 56 + final postTypes = Map<AtUri, bool>.from(currentState?.postTypes ?? {}); 57 + final postViews = Map<AtUri, PostView>.from(currentState?.postViews ?? {}); 58 + final allPosts = List<AtUri>.from(currentState?.allPosts ?? []); 59 + 60 + final sparkRkeys = allPosts.where((uri) => postSources[uri] == 'sprk').map((uri) => uri.rkey).toSet(); 61 + 62 + final newPosts = <PostView>[]; 63 + 64 + final sparkResult = await _fetchFromSource( 65 + (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor, bluesky: false), 66 + sparkCursor, 67 + 'Sprk', 68 + ); 69 + 70 + for (final feedViewPost in sparkResult.posts) { 71 + final uri = feedViewPost.post.uri; 72 + if (!postViews.containsKey(uri)) { 73 + newPosts.add(feedViewPost.post); 74 + postSources[uri] = 'sprk'; 75 + postTypes[uri] = feedViewPost.post.videoUrl.isNotEmpty; 76 + postViews[uri] = feedViewPost.post; 77 + sparkRkeys.add(uri.rkey); 39 78 } 40 - final posts = result.posts.map((feedViewPost) => feedViewPost.post.uri).toList(); 79 + } 41 80 42 - _logger.d('Loaded initial ${result.posts.length} posts for profile ${profileUri.toString()}, videosOnly: $videosOnly'); 81 + final bskyResult = await _fetchFromSource( 82 + (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor, bluesky: true), 83 + blueskyCursor, 84 + 'Bsky', 85 + ); 43 86 44 - return ProfileFeedState( 45 - loadedPosts: posts, 46 - isEndOfNetwork: result.posts.length < ProfileFeedState.fetchLimit, 47 - cursor: result.cursor, 48 - extraInfo: LinkedHashMap(), 49 - ); 87 + for (final feedViewPost in bskyResult.posts) { 88 + final uri = feedViewPost.post.uri; 89 + if (sparkRkeys.contains(uri.rkey) || postViews.containsKey(uri)) { 90 + _logger.d('Skipping Bsky post with rkey ${uri.rkey} - already exists.'); 91 + continue; 92 + } 93 + newPosts.add(feedViewPost.post); 94 + postSources[uri] = 'bsky'; 95 + postTypes[uri] = _isEmbedVideo(feedViewPost.post.embed); 96 + postViews[uri] = feedViewPost.post; 97 + } 98 + 99 + newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 100 + allPosts.addAll(newPosts.map((post) => post.uri)); 101 + 102 + final filteredPosts = videosOnly 103 + ? allPosts.where((uri) => postTypes[uri] == true).toList() 104 + : allPosts.where((uri) => postTypes[uri] == false).toList(); 105 + 106 + final isEndOfNetwork = 107 + (sparkResult.cursor == null && bskyResult.cursor == null) || 108 + (currentState != null && currentState.loadedPosts.length == filteredPosts.length); 109 + 110 + return ProfileFeedState( 111 + loadedPosts: filteredPosts, 112 + allPosts: allPosts, 113 + isEndOfNetwork: isEndOfNetwork, 114 + cursor: sparkResult.cursor, 115 + blueskyCursor: bskyResult.cursor, 116 + // ignore: prefer_collection_literals 117 + extraInfo: currentState?.extraInfo ?? LinkedHashMap(), 118 + postSources: postSources, 119 + postTypes: postTypes, 120 + postViews: postViews, 121 + ); 122 + } 123 + 124 + Future<({List<FeedViewPost> posts, String? cursor})> _fetchFromSource( 125 + _FeedSourceFetcher fetcher, 126 + String? cursor, 127 + String sourceName, 128 + ) async { 129 + try { 130 + final result = await fetcher(cursor); 131 + _logger.d('Loaded ${result.posts.length} posts from $sourceName'); 132 + return result; 50 133 } catch (e) { 51 - _logger.e('Error loading initial posts: $e'); 52 - // Rethrow the error so the provider enters error state 53 - rethrow; 134 + _logger.w('Failed to load from $sourceName: $e'); 135 + return (posts: <FeedViewPost>[], cursor: cursor); 54 136 } 55 137 } 56 138 57 - /// Load more posts for the profile 139 + bool _isEmbedVideo(EmbedView? embed) { 140 + if (embed == null) return false; 141 + return embed.when( 142 + video: (cid, playlist, thumbnail, alt) => true, 143 + bskyVideo: (cid, playlist, thumbnail, alt) => true, 144 + bskyRecordWithMedia: (record, media) => _isEmbedVideo(media), 145 + image: (images) => false, 146 + bskyImages: (images) => false, 147 + bskyRecord: (record) => false, 148 + bskyRecordViewRecord: 149 + (uri, cid, author, value, indexedAt, labels, replyCount, repostCount, likeCount, quoteCount, embeds) => false, 150 + bskyExternal: (external, cid) => false, 151 + ); 152 + } 153 + 58 154 Future<void> loadMore() async { 59 155 if (_isLoading || state.value?.isEndOfNetwork == true) return; 60 156 61 157 _isLoading = true; 62 158 final currentState = state.value; 63 - if (currentState == null) return; 159 + if (currentState == null) { 160 + _isLoading = false; 161 + return; 162 + } 64 163 65 164 try { 66 - final result = await _feedRepository.getAuthorFeed( 67 - profileUri, 68 - limit: ProfileFeedState.fetchLimit, 69 - cursor: currentState.cursor, 165 + final result = await _loadUnifiedFeed( 166 + profileUri: profileUri, 167 + sparkCursor: currentState.cursor, 168 + blueskyCursor: currentState.blueskyCursor, 70 169 videosOnly: videosOnly, 170 + currentState: currentState, 71 171 ); 72 172 73 - final newPosts = result.posts.map((feedViewPost) => feedViewPost.post.uri).toList(); 74 - final allPosts = [...currentState.loadedPosts, ...newPosts]; 173 + state = AsyncValue.data(result); 75 174 76 - state = AsyncValue.data( 77 - currentState.copyWith( 78 - loadedPosts: allPosts, 79 - cursor: result.cursor, 80 - isEndOfNetwork: result.posts.length < ProfileFeedState.fetchLimit, 81 - ), 82 - ); 83 - 84 - // Cache the posts 85 - final postViews = result.posts.map((post) => post.post).toList(); 86 - await _sqlCache.cachePosts(postViews); 175 + final newPostUris = result.allPosts.where((uri) => !currentState.allPosts.contains(uri)).toList(); 176 + if (newPostUris.isNotEmpty) { 177 + final postViewsToCache = newPostUris.map((uri) => result.postViews[uri]!).toList(); 178 + await _sqlCache.cachePosts(postViewsToCache); 179 + } 87 180 88 - _logger.d('Loaded ${result.posts.length} posts for profile ${profileUri.toString()}, videosOnly: $videosOnly'); 181 + _logger.d('Loaded more posts: ${result.allPosts.length - currentState.allPosts.length} new posts'); 89 182 } catch (e) { 90 183 _logger.e('Error loading more posts: $e'); 91 184 state = AsyncValue.error(e, StackTrace.current); ··· 94 187 } 95 188 } 96 189 97 - /// Refresh the profile feed 98 190 Future<void> refresh() async { 99 - state = const AsyncValue.loading(); 100 - final freshState = ProfileFeedState(loadedPosts: [], isEndOfNetwork: false, cursor: null, extraInfo: LinkedHashMap()); 101 - state = AsyncValue.data(freshState); 102 - await loadMore(); 191 + try { 192 + final result = await _loadUnifiedFeed( 193 + profileUri: profileUri, 194 + sparkCursor: null, 195 + blueskyCursor: null, 196 + videosOnly: videosOnly, 197 + ); 198 + state = AsyncValue.data(result); 199 + } catch (e) { 200 + _logger.e('Error refreshing posts: $e'); 201 + state = AsyncValue.error(e, StackTrace.current); 202 + } 103 203 } 104 204 }
+9
lib/src/features/profile/providers/profile_feed_state.dart
··· 15 15 required bool isEndOfNetwork, 16 16 required String? cursor, 17 17 required LinkedHashMap<AtUri, ({List<Label> postLabels, HardcodedFeedExtraInfo? hardcodedFeedExtraInfo})> extraInfo, 18 + @Default(<AtUri>[]) List<AtUri> allPosts, 19 + @Default(<AtUri, String>{}) Map<AtUri, String> postSources, 20 + @Default(<AtUri, bool>{}) Map<AtUri, bool> postTypes, 21 + @Default(<AtUri, PostView>{}) Map<AtUri, PostView> postViews, 22 + @Default(null) String? blueskyCursor, 18 23 }) = _ProfileFeedState; 19 24 20 25 int get length => loadedPosts.length; 26 + int get allPostsLength => allPosts.length; 27 + 28 + List<AtUri> get videoPosts => allPosts.where((uri) => postTypes[uri] == true).toList(); 29 + List<AtUri> get imagePosts => allPosts.where((uri) => postTypes[uri] == false).toList(); 21 30 22 31 static const int fetchLimit = 16; // number of posts to fetch at a time 23 32 // NO CACHING HAHAHAHAHA https://pbs.twimg.com/media/Gibzch0aYAU4WXZ.jpg:large
+13 -3
lib/src/features/profile/providers/profile_provider.dart
··· 9 9 import 'package:sparksocial/src/features/profile/providers/profile_state.dart'; 10 10 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 11 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 12 + import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 12 13 13 14 part 'profile_provider.g.dart'; 14 15 ··· 84 85 final didToRefresh = currentDid ?? authRepository.session!.did; 85 86 86 87 try { 87 - // Load fresh data directly without changing to loading state 88 - await loadProfileData(didToRefresh, currentProfileState ?? ProfileState(currentViewDid: didToRefresh)); 89 - logger.i('Profile for $didToRefresh refreshed successfully.'); 88 + final profileUri = AtUri.parse('at://$didToRefresh'); 89 + final profileRefreshFuture = loadProfileData( 90 + didToRefresh, 91 + currentProfileState ?? ProfileState(currentViewDid: didToRefresh), 92 + ); 93 + 94 + final videosRefreshFuture = ref.read(profileFeedProvider(profileUri, true).notifier).refresh(); 95 + final photosRefreshFuture = ref.read(profileFeedProvider(profileUri, false).notifier).refresh(); 96 + 97 + await Future.wait([profileRefreshFuture, videosRefreshFuture, photosRefreshFuture]); 98 + 99 + logger.i('Profile and feeds for $didToRefresh refreshed successfully.'); 90 100 } catch (e, s) { 91 101 logger.e('Error refreshing profile for $didToRefresh', error: e, stackTrace: s); 92 102 // If we have current data, keep it; otherwise show error
+4 -10
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 35 35 36 36 try { 37 37 // Try to get from cache first 38 - final cachedPost = await sqlCache.getPost(widget.postUri.toString()); 39 - return cachedPost; 38 + return await sqlCache.getPost(widget.postUri.toString()); 40 39 } catch (e) { 41 40 // Cache lookup failed, continue to network fetch 42 41 } ··· 44 43 // If cache is null or fails, fetch from network 45 44 final feedRepository = GetIt.instance<SprkRepository>().feed; 46 45 47 - List<PostView> networkPost; 48 - try { 49 - // Try Spark network first 50 - networkPost = await feedRepository.getPosts([widget.postUri], bluesky: false); 51 - } catch (e) { 52 - // Fallback to Bluesky network 53 - networkPost = await feedRepository.getPosts([widget.postUri], bluesky: true); 54 - } 46 + final uri = AtUri.parse(widget.postUri.toString()); 47 + final isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 48 + final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 55 49 56 50 if (networkPost.isEmpty) { 57 51 return null;
+78 -155
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 4 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 - import 'package:get_it/get_it.dart'; 7 + import 'package:flutter_svg/flutter_svg.dart'; 8 8 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 9 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 10 9 import 'package:sparksocial/src/core/routing/app_router.dart'; 11 - import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 12 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 13 11 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 14 12 ··· 29 27 void initState() { 30 28 super.initState(); 31 29 _scrollController.addListener(_onScroll); 32 - 33 - // Initial data loading is now handled automatically by the provider 34 - } 35 - 36 - @override 37 - void didUpdateWidget(ProfileGridWidget oldWidget) { 38 - super.didUpdateWidget(oldWidget); 39 - 40 - // When videosOnly parameter or profileUri changes, a new provider instance 41 - // will be created and will automatically load its data 42 30 } 43 31 44 32 @override ··· 49 37 50 38 void _onScroll() { 51 39 if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 52 - // Load more when user is near the bottom 53 40 ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).loadMore(); 54 41 } 55 42 } 56 43 57 - Future<void> _onRefresh() async { 58 - await ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).refresh(); 59 - } 60 - 61 44 @override 62 45 Widget build(BuildContext context) { 63 46 final feedState = ref.watch(profileFeedProvider(widget.profileUri, widget.videosOnly)); ··· 84 67 ); 85 68 } 86 69 87 - return RefreshIndicator( 88 - onRefresh: _onRefresh, 89 - child: GridView.builder( 90 - controller: _scrollController, 91 - padding: const EdgeInsets.all(1), 92 - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 93 - crossAxisCount: 4, 94 - crossAxisSpacing: 1, 95 - mainAxisSpacing: 1, 96 - childAspectRatio: 0.6, 97 - ), 98 - itemCount: state.loadedPosts.length + (state.isEndOfNetwork ? 0 : 1), 99 - itemBuilder: (context, index) { 100 - if (index >= state.loadedPosts.length) { 101 - // Loading indicator 102 - return Container( 103 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 104 - child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), 105 - ); 106 - } 70 + return GridView.builder( 71 + controller: _scrollController, 72 + padding: const EdgeInsets.all(1), 73 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 74 + crossAxisCount: 4, 75 + crossAxisSpacing: 1, 76 + mainAxisSpacing: 1, 77 + childAspectRatio: 0.6, 78 + ), 79 + itemCount: state.loadedPosts.length + (state.isEndOfNetwork ? 0 : 1), 80 + itemBuilder: (context, index) { 81 + if (index >= state.loadedPosts.length) { 82 + return Container( 83 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 84 + child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), 85 + ); 86 + } 87 + 88 + final postUri = state.loadedPosts[index]; 89 + final postView = state.postViews[postUri]; 90 + final postSource = state.postSources[postUri]; 91 + 92 + if (postView == null) { 93 + return const SizedBox.shrink(); 94 + } 107 95 108 - final postUri = state.loadedPosts[index]; 109 - return ProfileGridTile(postUri: postUri, videosOnly: widget.videosOnly, onTap: () => _onPostTap(postUri)); 110 - }, 111 - ), 96 + return ProfileGridTile(postView: postView, postSource: postSource, onTap: () => _onPostTap(postUri)); 97 + }, 112 98 ); 113 99 }, 114 100 loading: () => const Center(child: CircularProgressIndicator()), ··· 120 106 const SizedBox(height: 16), 121 107 Text('Error loading posts: $error'), 122 108 const SizedBox(height: 16), 123 - ElevatedButton(onPressed: _onRefresh, child: const Text('Retry')), 109 + ElevatedButton( 110 + onPressed: () => ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly).notifier).refresh(), 111 + child: const Text('Retry'), 112 + ), 124 113 ], 125 114 ), 126 115 ), ··· 128 117 } 129 118 130 119 void _onPostTap(AtUri postUri) { 131 - // Find the index of the tapped post in the loaded posts 132 120 final feedState = ref.read(profileFeedProvider(widget.profileUri, widget.videosOnly)); 133 121 feedState.whenData((state) { 134 122 final postIndex = state.loadedPosts.indexOf(postUri); 135 123 if (postIndex != -1) { 136 - // Navigate to standalone profile feed page starting at the tapped post 137 - context.router.push(StandaloneProfileFeedRoute( 138 - profileUri: widget.profileUri.toString(), 139 - videosOnly: widget.videosOnly, 140 - initialPostIndex: postIndex, 141 - )); 124 + context.router.push( 125 + StandaloneProfileFeedRoute( 126 + profileUri: widget.profileUri.toString(), 127 + videosOnly: widget.videosOnly, 128 + initialPostIndex: postIndex, 129 + ), 130 + ); 142 131 } else { 143 - // Fallback to standalone post page if post not found in feed 144 132 context.router.push(StandalonePostRoute(postUri: postUri.toString())); 145 133 } 146 134 }); ··· 148 136 } 149 137 150 138 class ProfileGridTile extends StatelessWidget { 151 - final AtUri postUri; 152 - final bool videosOnly; 139 + final PostView postView; 140 + final String? postSource; 153 141 final VoidCallback onTap; 154 142 155 - const ProfileGridTile({super.key, required this.postUri, required this.videosOnly, required this.onTap}); 156 - 157 - Future<PostView?> _loadPostWithFallback() async { 158 - final sqlCache = GetIt.instance<SQLCacheInterface>(); 159 - 160 - try { 161 - // Try to get from cache first 162 - final cachedPost = await sqlCache.getPost(postUri.toString()); 163 - return cachedPost; 164 - } catch (e) { 165 - // Cache lookup failed, continue to network fetch 166 - } 167 - 168 - // If cache is null or fails, fetch from network 169 - final feedRepository = GetIt.instance<SprkRepository>().feed; 170 - 171 - List<PostView> networkPost; 172 - try { 173 - // Try Spark network first 174 - networkPost = await feedRepository.getPosts([postUri], bluesky: false); 175 - } catch (e) { 176 - // Fallback to Bluesky network 177 - networkPost = await feedRepository.getPosts([postUri], bluesky: true); 178 - } 179 - 180 - if (networkPost.isEmpty) { 181 - return null; 182 - } 183 - 184 - // Cache the post for future use 185 - await sqlCache.cachePost(networkPost.first); 186 - 187 - return networkPost.first; 188 - } 143 + const ProfileGridTile({super.key, required this.postView, this.postSource, required this.onTap}); 189 144 190 145 @override 191 146 Widget build(BuildContext context) { 192 - return FutureBuilder<PostView?>( 193 - future: _loadPostWithFallback(), 194 - builder: (context, snapshot) { 195 - if (snapshot.connectionState == ConnectionState.waiting) { 196 - return const SizedBox.shrink(); 197 - } 147 + final thumbnailUrl = postView.thumbnailUrl; 198 148 199 - if (snapshot.hasError || !snapshot.hasData) { 200 - return const SizedBox.shrink(); 201 - } 202 - 203 - final post = snapshot.data!; 204 - final thumbnailUrl = post.thumbnailUrl; 205 - 206 - return GestureDetector( 207 - onTap: onTap, 208 - child: Container( 209 - color: AppColors.black, 210 - child: thumbnailUrl.isNotEmpty 211 - ? Stack( 212 - fit: StackFit.expand, 213 - children: [ 214 - CachedNetworkImage( 215 - imageUrl: thumbnailUrl, 216 - fit: BoxFit.cover, 217 - placeholder: (context, url) => const SizedBox.shrink(), 218 - errorWidget: (context, url, error) => Container( 219 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 220 - child: const Center(child: Icon(FluentIcons.error_circle_24_regular, size: 20)), 221 - ), 222 - ), 223 - // Video indicator overlay for videos 224 - if (post.embed is EmbedViewVideo) 225 - Positioned( 226 - top: 4, 227 - right: 4, 228 - child: Container( 229 - padding: const EdgeInsets.all(4), 230 - decoration: BoxDecoration(color: Colors.black.withAlpha(150), borderRadius: BorderRadius.circular(4)), 231 - child: const Icon(FluentIcons.play_24_filled, color: Colors.white, size: 12), 232 - ), 149 + return GestureDetector( 150 + onTap: onTap, 151 + child: Container( 152 + color: AppColors.black, 153 + child: thumbnailUrl.isNotEmpty 154 + ? Stack( 155 + fit: StackFit.expand, 156 + children: [ 157 + CachedNetworkImage( 158 + imageUrl: thumbnailUrl, 159 + fit: BoxFit.cover, 160 + placeholder: (context, url) => const SizedBox.shrink(), 161 + errorWidget: (context, url, error) => Container( 162 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 163 + child: const Center(child: Icon(FluentIcons.error_circle_24_regular, size: 20)), 164 + ), 165 + ), 166 + if (postSource != null) 167 + Positioned( 168 + top: 4, 169 + right: 4, 170 + child: Container( 171 + padding: const EdgeInsets.all(4), 172 + decoration: BoxDecoration(color: Colors.black.withAlpha(150), borderRadius: BorderRadius.circular(4)), 173 + child: SvgPicture.asset( 174 + postSource == 'bsky' ? 'assets/images/bsky.svg' : 'assets/images/sprk.svg', 175 + width: 12, 176 + height: 12, 233 177 ), 234 - // Multiple image indicator for image carousels 235 - if (post.embed is EmbedViewImage) 236 - if ((post.embed as EmbedViewImage).images.length > 1) 237 - Positioned( 238 - top: 4, 239 - right: 4, 240 - child: Container( 241 - padding: const EdgeInsets.all(4), 242 - decoration: BoxDecoration( 243 - color: Colors.black.withAlpha(150), 244 - borderRadius: BorderRadius.circular(4), 245 - ), 246 - child: const Icon(FluentIcons.copy_24_regular, color: Colors.white, size: 12), 247 - ), 248 - ), 249 - ], 250 - ) 251 - : Container( 252 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 253 - child: Center( 254 - child: Icon( 255 - post.embed is EmbedViewVideo ? FluentIcons.video_24_regular : FluentIcons.image_24_regular, 256 - color: Theme.of(context).colorScheme.onSurfaceVariant, 257 - size: 24, 258 178 ), 259 179 ), 260 - ), 261 - ), 262 - ); 263 - }, 180 + ], 181 + ) 182 + : Container( 183 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 184 + child: const Center(child: Icon(FluentIcons.image_off_24_regular)), 185 + ), 186 + ), 264 187 ); 265 188 } 266 189 }