[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.

Fnt 145 the comment situation (#66)

Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

Jean Carlo Polo
daviirodrig
and committed by
GitHub
b4c79261 f3e16ec0

+264 -67
+215 -28
lib/src/core/network/atproto/data/models/feed_models.dart
··· 1 1 import 'package:atproto/atproto.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/bluesky.dart' as bsky; 3 4 import 'package:flutter/foundation.dart'; 4 5 import 'package:freezed_annotation/freezed_annotation.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; ··· 223 224 224 225 bool get isSprk => RegExp(r'^at://[^/]+/so\.sprk\.feed\.post/[^/]+$').hasMatch(uri.toString()); 225 226 227 + /// Returns true if this post has a video or image embed (content we want to show) 228 + bool get hasSupportedMedia { 229 + if (embed == null) return false; 230 + 231 + switch (embed) { 232 + case EmbedViewVideo(): 233 + case EmbedViewBskyVideo(): 234 + case EmbedViewImage(): 235 + case EmbedViewBskyImages(): 236 + return true; 237 + case EmbedViewBskyRecordWithMedia(:final media): 238 + // Check nested media in record with media 239 + switch (media) { 240 + case EmbedViewVideo(): 241 + case EmbedViewBskyVideo(): 242 + case EmbedViewImage(): 243 + case EmbedViewBskyImages(): 244 + return true; 245 + case _: 246 + return false; 247 + } 248 + case _: 249 + return false; 250 + } 251 + } 252 + 226 253 /// Resolves AT Protocol blob URLs to HTTP URLs for display 227 254 String _resolveAtUriToHttpUrl(AtUri atUri, {bool isFullsize = false}) { 228 255 final uriString = atUri.toString(); ··· 256 283 return uriString; 257 284 } 258 285 259 - /// Returns true if this post has a video or image embed (content we want to show) 260 - bool get hasSupportedMedia { 261 - if (embed == null) return false; 262 - 263 - switch (embed) { 264 - case EmbedViewVideo(): 265 - case EmbedViewBskyVideo(): 266 - case EmbedViewImage(): 267 - case EmbedViewBskyImages(): 268 - return true; 269 - case EmbedViewBskyRecordWithMedia(:final media): 270 - // Check nested media in record with media 271 - switch (media) { 272 - case EmbedViewVideo(): 273 - case EmbedViewBskyVideo(): 274 - case EmbedViewImage(): 275 - case EmbedViewBskyImages(): 276 - return true; 277 - case _: 278 - return false; 279 - } 280 - case _: 281 - return false; 282 - } 283 - } 284 - 285 286 String get videoUrl { 286 287 switch (embed) { 287 288 case EmbedViewVideo(:final playlist): ··· 427 428 @JsonSerializable(explicitToJson: true) 428 429 const factory EmbedViewExternal({ 429 430 required String uri, 430 - required String title, 431 - required String description, 431 + @Default('') String title, 432 + @Default('') String description, 432 433 @AtUriConverter() AtUri? thumb, 433 434 }) = _EmbedViewExternal; 434 435 ··· 550 551 class Thread with _$Thread { 551 552 const Thread._(); 552 553 554 + // NORMAL POST 553 555 @FreezedUnionValue('so.sprk.feed.defs#threadViewPost') 554 556 @JsonSerializable(explicitToJson: true) 555 557 const factory Thread.threadViewPost({required PostView post, Thread? parent, List<Thread>? replies, ThreadContext? context}) = 556 558 ThreadViewPost; 557 559 560 + // NOT FOUND POST 558 561 @FreezedUnionValue('so.sprk.feed.defs#notFoundPost') 559 562 @JsonSerializable(explicitToJson: true) 560 563 const factory Thread.notFoundPost({@AtUriConverter() required AtUri uri, required bool notFound}) = NotFoundPost; 561 564 565 + // BLOCKED POST 562 566 @FreezedUnionValue('so.sprk.feed.defs#blockedPost') 563 567 @JsonSerializable(explicitToJson: true) 564 568 const factory Thread.blockedPost({@AtUriConverter() required AtUri uri, required bool blocked, required BlockedAuthor author}) = 565 569 BlockedPost; 566 570 567 571 factory Thread.fromJson(Map<String, dynamic> json) => _$ThreadFromJson(json); 572 + 573 + factory Thread.fromBsky({required bsky.PostThreadView thread, required AtUri uri}) { 574 + switch (thread) { 575 + case bsky.UPostThreadViewRecord(:final data): 576 + try { 577 + bsky.EmbedView? embed = data.post.embed; 578 + if (data.post.embed is bsky.UEmbedViewExternal) { 579 + embed = null; 580 + } 581 + final postJson = data.post.copyWith(embed: embed); 582 + 583 + // Create PostView with safer parsing 584 + final postViewJson = postJson.toJson(); 585 + 586 + // Ensure required fields are not null 587 + if (postViewJson['cid'] == null) { 588 + throw Exception('Post cid is null'); 589 + } 590 + if (postViewJson['uri'] == null) { 591 + throw Exception('Post uri is null'); 592 + } 593 + if (postViewJson['author'] == null) { 594 + throw Exception('Post author is null'); 595 + } 596 + if (postViewJson['record'] == null) { 597 + throw Exception('Post record is null'); 598 + } 599 + if (postViewJson['indexedAt'] == null) { 600 + throw Exception('Post indexedAt is null'); 601 + } 602 + 603 + // Ensure author required fields are not null 604 + final authorJson = postViewJson['author'] as Map<String, dynamic>; 605 + if (authorJson['did'] == null) { 606 + throw Exception('Author did is null'); 607 + } 608 + if (authorJson['handle'] == null) { 609 + throw Exception('Author handle is null'); 610 + } 611 + 612 + // Check embed data if present - this is where the error is occurring 613 + if (postViewJson['embed'] != null) { 614 + final embedJson = postViewJson['embed'] as Map<String, dynamic>; 615 + 616 + // Check for external embed without required cid 617 + if (embedJson[r'$type'] == 'app.bsky.embed.external#view') { 618 + if (embedJson['cid'] == null) { 619 + postViewJson.remove('embed'); 620 + } 621 + } 622 + 623 + // If it's a record embed, check the record data 624 + if (embedJson[r'$type'] == 'app.bsky.embed.record#view' && embedJson['record'] != null) { 625 + final recordJson = embedJson['record'] as Map<String, dynamic>; 626 + 627 + // Check required fields for EmbedViewBskyRecordViewRecord 628 + if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 629 + if (recordJson['cid'] == null) { 630 + postViewJson.remove('embed'); 631 + } 632 + if (recordJson['uri'] == null) { 633 + postViewJson.remove('embed'); 634 + } 635 + if (recordJson['author'] == null) { 636 + postViewJson.remove('embed'); 637 + } 638 + if (recordJson['value'] == null) { 639 + postViewJson.remove('embed'); 640 + } 641 + if (recordJson['indexedAt'] == null) { 642 + postViewJson.remove('embed'); 643 + } 644 + 645 + // Check nested embeds array in the record value 646 + if (recordJson['embeds'] != null && recordJson['embeds'] is List) { 647 + final embedsList = recordJson['embeds'] as List; 648 + bool shouldRemoveEmbed = false; 649 + 650 + for (var nestedEmbed in embedsList) { 651 + if (nestedEmbed is Map<String, dynamic>) { 652 + // Check external embeds in the nested embeds 653 + if (nestedEmbed[r'$type'] == 'app.bsky.embed.external#view' && nestedEmbed['cid'] == null) { 654 + shouldRemoveEmbed = true; 655 + break; 656 + } 657 + } 658 + } 659 + 660 + if (shouldRemoveEmbed) { 661 + postViewJson.remove('embed'); 662 + } 663 + } 664 + } 665 + } 666 + 667 + // Enhanced check for recordWithMedia embeds 668 + if (embedJson[r'$type'] == 'app.bsky.embed.recordWithMedia#view') { 669 + // Check the record part 670 + if (embedJson['record'] != null) { 671 + final recordEmbedJson = embedJson['record'] as Map<String, dynamic>; 672 + if (recordEmbedJson['record'] != null) { 673 + final recordJson = recordEmbedJson['record'] as Map<String, dynamic>; 674 + 675 + // Check if it's a viewRecord and has required fields 676 + if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 677 + if (recordJson['uri'] == null || recordJson['cid'] == null || 678 + recordJson['author'] == null || recordJson['value'] == null || 679 + recordJson['indexedAt'] == null) { 680 + postViewJson.remove('embed'); 681 + } 682 + } 683 + } 684 + } 685 + } 686 + 687 + // Additional safety check - if we have any embed that might contain a record view, validate it 688 + void validateRecordViewInEmbed(Map<String, dynamic> embedData, String path) { 689 + if (embedData[r'$type'] == 'app.bsky.embed.record#viewRecord') { 690 + if (embedData['uri'] == null || embedData['cid'] == null || 691 + embedData['author'] == null || embedData['value'] == null || 692 + embedData['indexedAt'] == null) { 693 + postViewJson.remove('embed'); 694 + return; 695 + } 696 + } 697 + 698 + // Recursively check nested structures 699 + embedData.forEach((key, value) { 700 + if (value is Map<String, dynamic>) { 701 + validateRecordViewInEmbed(value, '$path.$key'); 702 + } else if (value is List) { 703 + for (int i = 0; i < value.length; i++) { 704 + if (value[i] is Map<String, dynamic>) { 705 + validateRecordViewInEmbed(value[i], '$path.$key[$i]'); 706 + } 707 + } 708 + } 709 + }); 710 + } 711 + 712 + // Run the validation on the entire embed structure 713 + if (postViewJson['embed'] != null) { 714 + validateRecordViewInEmbed(postViewJson['embed'] as Map<String, dynamic>, 'embed'); 715 + } 716 + } 717 + 718 + final thread = Thread.threadViewPost( 719 + post: PostView.fromJson(postViewJson), 720 + parent: data.parent != null ? Thread.fromBsky(thread: data.parent!, uri: uri) : null, 721 + replies: data.replies 722 + ?.map((reply) { 723 + switch (reply) { 724 + case bsky.UPostThreadViewRecord(:final data): 725 + return Thread.fromBsky(thread: reply, uri: data.post.uri); 726 + case bsky.UPostThreadViewNotFound(:final data): 727 + return Thread.notFoundPost(uri: data.uri, notFound: true); 728 + case bsky.UPostThreadViewBlocked(:final data): 729 + return Thread.blockedPost( 730 + uri: data.uri, 731 + blocked: true, 732 + author: BlockedAuthor.fromJson(data.author.toJson()), 733 + ); 734 + case bsky.UPostThreadViewUnknown(): 735 + // Skip unknown reply types by returning null 736 + return null; 737 + } 738 + }) 739 + .whereType<Thread>() 740 + .toList(), 741 + context: null, 742 + ); 743 + return thread; 744 + } catch (e) { 745 + rethrow; 746 + } 747 + case bsky.UPostThreadViewNotFound(): 748 + return Thread.notFoundPost(uri: uri, notFound: true); 749 + case bsky.UPostThreadViewBlocked(:final data): 750 + return Thread.blockedPost(uri: uri, blocked: true, author: BlockedAuthor.fromJson(data.author.toJson())); 751 + default: 752 + throw Exception('Unsupported thread type: ${thread.runtimeType}'); 753 + } 754 + } 568 755 } 569 756 570 757 @freezed
+16 -2
lib/src/core/network/atproto/data/models/records.dart
··· 46 46 DateTime? createdAt, 47 47 }) = ProfileRecord; 48 48 49 + @JsonSerializable(explicitToJson: true) 50 + @FreezedUnionValue('app.bsky.feed.post') 51 + const factory Record.bskyPost({ 52 + DateTime? createdAt, 53 + @JsonKey(defaultValue: '') String? text, 54 + @JsonKey(defaultValue: []) List<Facet>? facets, 55 + RecordReplyRef? reply, 56 + List<String>? langs, 57 + List<String>? tags, 58 + List<SelfLabel>? selfLabels, 59 + Embed? embed, // blob 60 + // threadgate 61 + }) = BskyPostRecord; 62 + 49 63 List<String> get hashtags { 50 64 switch (this) { 51 65 case PostRecord(:final tags, :final text): ··· 134 148 @JsonSerializable(explicitToJson: true) 135 149 const factory EmbedExternal({ 136 150 required String uri, 137 - required String title, 138 - required String description, 151 + @Default('') String title, 152 + @Default('') String description, 139 153 Blob? thumb, 140 154 }) = _EmbedExternal; 141 155
+1 -1
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 14 14 /// Get posts by URIs (hydrates a skeleton) 15 15 /// 16 16 /// [uris] List of post URIs to fetch 17 - Future<List<PostView>> getPosts(List<AtUri> uris, {bool bluesky = false}); 17 + Future<List<PostView>> getPosts(List<AtUri> uris, {bool bluesky = false, bool filter = true}); 18 18 19 19 /// Get an author's feed 20 20 ///
+21 -20
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 89 89 } 90 90 91 91 @override 92 - Future<List<PostView>> getPosts(List<AtUri> uris, {bool bluesky = false}) async { 92 + Future<List<PostView>> getPosts(List<AtUri> uris, {bool bluesky = false, bool filter = true}) async { 93 93 _logger.d('Getting posts for URIs: ${uris.length} URIs'); 94 94 if (bluesky) { 95 95 _logger.d('Getting posts on bluesky API for: ${uris.length} URIs'); 96 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>( 97 + final posts = await blueskyClient.feed.getPosts(uris: uris); 98 + final filteredPosts = filter ? _parseAndFilterPosts<PostView>( 99 99 rawPosts: posts.data.posts, 100 100 fromJson: PostView.fromJson, 101 101 getPostView: (post) => post, 102 102 source: 'bsky', 103 - ); 103 + ) : posts.data.posts.map((post) => PostView.fromJson(post.toJson())).toList(); 104 + return filteredPosts; 104 105 } 105 106 return _client.executeWithRetry(() async { 106 107 if (!_client.authRepository.isAuthenticated) { ··· 663 664 throw Exception('AtProto not initialized'); 664 665 } 665 666 666 - try { 667 - // Get the post thread 668 - final source = bluesky ? 'app.bsky.feed.getPostThread' : 'so.sprk.feed.getPostThread'; 669 - final response = await atproto.get( 670 - NSID.parse(source), 671 - parameters: {'uri': uri.toString(), 'depth': depth, 'parentHeight': parentHeight}, 672 - headers: {'atproto-proxy': _client.sprkDid}, 673 - to: (jsonMap) { 674 - return Thread.fromJson(jsonMap['thread'] as Map<String, dynamic>); 675 - }, 676 - ); 677 - 678 - return response.data; 679 - } catch (e) { 680 - _logger.e('Failed to load Bluesky comments', error: e); 681 - throw Exception('Failed to load comments: ${e.toString()}'); 667 + // Get the post thread 668 + if (bluesky) { 669 + final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 670 + final response = await bluesky.feed.getPostThread(uri: uri, depth: depth, parentHeight: parentHeight); 671 + return Thread.fromBsky(thread: response.data.thread, uri: uri); 682 672 } 673 + final source = 'so.sprk.feed.getPostThread'; 674 + final response = await atproto.get( 675 + NSID.parse(source), 676 + parameters: {'uri': uri.toString(), 'depth': depth, 'parentHeight': parentHeight}, 677 + headers: {'atproto-proxy': _client.sprkDid}, 678 + to: (jsonMap) { 679 + return Thread.fromJson(jsonMap['thread'] as Map<String, dynamic>); 680 + }, 681 + ); 682 + 683 + return response.data; 683 684 }); 684 685 } 685 686
+1 -1
lib/src/core/utils/logging/riverpod_logger.dart
··· 301 301 } 302 302 303 303 /// Truncates long strings to avoid excessive logging 304 - String _truncateIfNeeded(String text, {int maxLength = 300}) { 304 + String _truncateIfNeeded(String text, {int maxLength = 700}) { 305 305 if (text.length <= maxLength) { 306 306 return text; 307 307 }
+8 -14
lib/src/features/comments/providers/comments_page_provider.dart
··· 15 15 16 16 @override 17 17 Future<CommentsPageState> build({required AtUri postUri}) async { 18 - // Keep the provider alive to prevent unnecessary rebuilds 19 - ref.keepAlive(); 20 - 21 18 feedRepository = GetIt.instance<SprkRepository>().feed; 22 - // try to get from cache, if not found, fetch from network 23 - final sqlCache = GetIt.instance<SQLCacheInterface>(); 19 + final isBlueskyPost = postUri.collection.toString().startsWith('app.bsky.feed.post'); 20 + 24 21 try { 25 - final cachedPost = await sqlCache.getPost(postUri.toString()); 26 - final thread = await feedRepository.getThread(postUri, bluesky: !cachedPost.isSprk, depth: 1); 22 + final thread = await feedRepository.getThread(postUri, bluesky: isBlueskyPost, depth: 1); 27 23 switch (thread) { 28 24 case ThreadViewPost(): 29 25 return CommentsPageState(thread: thread); ··· 34 30 } 35 31 throw Exception('Post not found'); 36 32 } catch (e) { 37 - // If cache fails, fetch from network 38 - List<PostView> networkPost; 39 - try { 40 - networkPost = await feedRepository.getPosts([postUri], bluesky: false); 41 - } catch (e) { 42 - networkPost = await feedRepository.getPosts([postUri], bluesky: true); 33 + final networkPost = await feedRepository.getPosts([postUri], bluesky: isBlueskyPost, filter: false); 34 + if (networkPost.isEmpty) { 35 + throw Exception('No posts found at $postUri'); 43 36 } 44 - final thread = await feedRepository.getThread(postUri, bluesky: !networkPost.first.isSprk, depth: 1); 37 + 38 + final thread = await feedRepository.getThread(postUri, bluesky: isBlueskyPost, depth: 1); 45 39 switch (thread) { 46 40 case ThreadViewPost(): 47 41 return CommentsPageState(thread: thread);
+2 -1
lib/src/features/feed/providers/feed_provider.dart
··· 264 264 _logger.d('First load finished with ${uris.length} posts'); 265 265 } catch (e, stackTrace) { 266 266 _logger.e('Error in loadAndUpdateFirstLoad: $e', stackTrace: stackTrace); 267 - state = state.copyWith(loadingFirstLoad: false, error: true); 267 + state = state.copyWith(loadingFirstLoad: false, error: true, isEndOfNetworkFeed: true); 268 + _isWaitingForFreshPostsAtEnd = true; 268 269 } finally { 269 270 _isLoadingInProgress = false; 270 271 }