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

refactor adapters

+673 -361
+2
lib/src/core/network/atproto/data/adapters/adapters.dart
··· 1 + export 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/actor_adapter.dart'; 1 2 export 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 3 + export 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/repo_adapter.dart';
+51
lib/src/core/network/atproto/data/adapters/bsky/actor_adapter.dart
··· 1 + import 'package:bluesky/bluesky.dart' as bsky; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 + 4 + /// Adapter for Bluesky actor models <-> Spark actor models 5 + /// 6 + /// Handles bidirectional conversion between Bluesky actor/profile data structures 7 + /// and Spark actor/profile data structures. 8 + class BskyActorAdapter { 9 + const BskyActorAdapter(); 10 + 11 + // ============================================================================ 12 + // Bluesky -> Spark Conversions 13 + // ============================================================================ 14 + 15 + /// Get a single profile from Bluesky and convert to Spark format 16 + Future<ProfileViewDetailed> getProfileFromBluesky(bsky.Bluesky bluesky, String did) async { 17 + final profile = await bluesky.actor.getProfile(actor: did); 18 + return convertBskyProfileToSpark(profile.data.toJson()); 19 + } 20 + 21 + /// Get multiple profiles from Bluesky and convert to Spark format 22 + Future<List<ProfileViewDetailed>> getProfilesFromBluesky(bsky.Bluesky bluesky, List<String> dids) async { 23 + final profiles = await bluesky.actor.getProfiles(actors: dids); 24 + return profiles.data.profiles.map((p) => convertBskyProfileToSpark(p.toJson())).toList(); 25 + } 26 + 27 + /// Convert a Bluesky profile JSON to Spark ProfileViewDetailed 28 + ProfileViewDetailed convertBskyProfileToSpark(Map<String, dynamic> bskyProfileJson) { 29 + // Bluesky and Spark profile structures are compatible, just parse directly 30 + return ProfileViewDetailed.fromJson(bskyProfileJson); 31 + } 32 + 33 + /// Convert a Bluesky ProfileView JSON to Spark ProfileView 34 + ProfileView convertBskyProfileViewToSpark(Map<String, dynamic> bskyProfileJson) { 35 + return ProfileView.fromJson(bskyProfileJson); 36 + } 37 + 38 + /// Convert a Bluesky ProfileViewBasic JSON to Spark ProfileViewBasic 39 + ProfileViewBasic convertBskyProfileViewBasicToSpark(Map<String, dynamic> bskyProfileJson) { 40 + return ProfileViewBasic.fromJson(bskyProfileJson); 41 + } 42 + } 43 + 44 + /// Singleton instance of the Bluesky actor adapter 45 + /// 46 + /// Use this instance for all actor/profile model conversions: 47 + /// ```dart 48 + /// final bluesky = Bluesky.fromSession(session); 49 + /// final profile = await bskyActorAdapter.getProfileFromBluesky(bluesky, did); 50 + /// ``` 51 + const bskyActorAdapter = BskyActorAdapter();
+450 -1
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_embed_images.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart' as bsky_defs; 3 4 import 'package:bluesky/app_bsky_feed_getpostthread.dart'; 4 5 import 'package:bluesky/app_bsky_feed_post.dart'; 5 6 import 'package:bluesky/app_bsky_richtext_facet.dart'; ··· 15 16 const BskyFeedAdapter(); 16 17 17 18 // ============================================================================ 19 + // Bluesky Embed Filtering & Sanitization 20 + // ============================================================================ 21 + 22 + /// List of unsupported embed types that should be filtered out 23 + static const unsupportedEmbedTypes = [ 24 + 'app.bsky.graph.defs#starterPackViewBasic', 25 + 'app.bsky.graph.defs#listViewBasic', 26 + 'app.bsky.feed.defs#generatorView', 27 + 'app.bsky.labeler.defs#labelerView', 28 + ]; 29 + 30 + /// Check if an embed type is unsupported 31 + static bool isUnsupportedEmbedType(String? type) { 32 + return type != null && unsupportedEmbedTypes.contains(type); 33 + } 34 + 35 + /// Check if a post JSON has an EmbedViewRecord embed (quote post) 36 + /// These can cause deserialization issues and should be filtered from replies 37 + bool hasEmbedViewRecord(Map<String, dynamic> postJson) { 38 + bool checkForRecordEmbed(Map<String, dynamic> embedData) { 39 + final embedType = embedData[r'$type'] as String?; 40 + 41 + // Check if this is a record embed 42 + if (embedType == 'app.bsky.embed.record#view' && embedData['record'] != null) { 43 + return true; 44 + } 45 + 46 + // Check if this is a recordWithMedia embed 47 + if (embedType == 'app.bsky.embed.recordWithMedia#view' && embedData['record'] != null) { 48 + return true; 49 + } 50 + 51 + // Recursively check nested structures 52 + for (final value in embedData.values) { 53 + if (value is Map<String, dynamic>) { 54 + if (checkForRecordEmbed(value)) return true; 55 + } else if (value is List) { 56 + for (final item in value) { 57 + if (item is Map<String, dynamic> && checkForRecordEmbed(item)) { 58 + return true; 59 + } 60 + } 61 + } 62 + } 63 + 64 + return false; 65 + } 66 + 67 + // Check post-level embed 68 + if (postJson['embed'] != null && postJson['embed'] is Map<String, dynamic>) { 69 + if (checkForRecordEmbed(postJson['embed'] as Map<String, dynamic>)) { 70 + return true; 71 + } 72 + } 73 + 74 + // Check record-level embed 75 + if (postJson['record'] != null && postJson['record'] is Map<String, dynamic>) { 76 + final record = postJson['record'] as Map<String, dynamic>; 77 + if (record['embed'] != null && record['embed'] is Map<String, dynamic>) { 78 + if (checkForRecordEmbed(record['embed'] as Map<String, dynamic>)) { 79 + return true; 80 + } 81 + } 82 + } 83 + 84 + return false; 85 + } 86 + 87 + /// Check if a reply should be filtered out (has problematic embeds) 88 + bool shouldFilterReply(bsky_defs.PostView post) { 89 + final replyJson = post.toJson(); 90 + return hasEmbedViewRecord(replyJson); 91 + } 92 + 93 + /// Sanitize a Bluesky post JSON by removing/fixing problematic embeds 94 + /// Returns the sanitized JSON (modifies in place) 95 + void sanitizeBskyPostViewJson(Map<String, dynamic> postViewJson) { 96 + if (postViewJson['embed'] == null) return; 97 + 98 + final embedJson = postViewJson['embed'] as Map<String, dynamic>; 99 + 100 + // Check for external embed without required cid 101 + if (embedJson[r'$type'] == 'app.bsky.embed.external#view') { 102 + if (embedJson['cid'] == null) { 103 + postViewJson.remove('embed'); 104 + return; 105 + } 106 + } 107 + 108 + // If it's a record embed, check the record data 109 + if (embedJson[r'$type'] == 'app.bsky.embed.record#view' && embedJson['record'] != null) { 110 + final recordJson = embedJson['record'] as Map<String, dynamic>; 111 + 112 + // Filter out unsupported record embed types 113 + final recordType = recordJson[r'$type'] as String?; 114 + if (isUnsupportedEmbedType(recordType)) { 115 + postViewJson.remove('embed'); 116 + return; 117 + } 118 + 119 + // Check required fields for EmbedViewRecord#viewRecord 120 + if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 121 + if (recordJson['cid'] == null || 122 + recordJson['uri'] == null || 123 + recordJson['author'] == null || 124 + recordJson['value'] == null || 125 + recordJson['indexedAt'] == null) { 126 + postViewJson.remove('embed'); 127 + return; 128 + } 129 + 130 + // Check nested embeds array in the record value 131 + if (recordJson['embeds'] != null && recordJson['embeds'] is List) { 132 + final embedsList = recordJson['embeds'] as List; 133 + for (final nestedEmbed in embedsList) { 134 + if (nestedEmbed is Map<String, dynamic>) { 135 + // Check external embeds in the nested embeds 136 + if (nestedEmbed[r'$type'] == 'app.bsky.embed.external#view' && nestedEmbed['cid'] == null) { 137 + postViewJson.remove('embed'); 138 + return; 139 + } 140 + } 141 + } 142 + } 143 + } 144 + } 145 + 146 + // Enhanced check for recordWithMedia embeds 147 + if (embedJson[r'$type'] == 'app.bsky.embed.recordWithMedia#view') { 148 + if (embedJson['record'] != null) { 149 + final recordEmbedJson = embedJson['record'] as Map<String, dynamic>; 150 + if (recordEmbedJson['record'] != null) { 151 + final recordJson = recordEmbedJson['record'] as Map<String, dynamic>; 152 + 153 + // Filter out unsupported record embed types 154 + final recordType = recordJson[r'$type'] as String?; 155 + if (isUnsupportedEmbedType(recordType)) { 156 + postViewJson.remove('embed'); 157 + return; 158 + } 159 + 160 + // Check if it's a viewRecord and has required fields 161 + if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 162 + if (recordJson['uri'] == null || 163 + recordJson['cid'] == null || 164 + recordJson['author'] == null || 165 + recordJson['value'] == null || 166 + recordJson['indexedAt'] == null) { 167 + postViewJson.remove('embed'); 168 + return; 169 + } 170 + } 171 + } 172 + } 173 + } 174 + 175 + // Recursive validation for any remaining embed structures 176 + if (postViewJson['embed'] != null) { 177 + _validateRecordViewInEmbed(postViewJson['embed'] as Map<String, dynamic>, postViewJson); 178 + } 179 + } 180 + 181 + /// Recursively validate record views in embed structures 182 + void _validateRecordViewInEmbed(Map<String, dynamic> embedData, Map<String, dynamic> postViewJson) { 183 + final embedType = embedData[r'$type'] as String?; 184 + 185 + // Filter out unsupported record embed types 186 + if (isUnsupportedEmbedType(embedType)) { 187 + postViewJson.remove('embed'); 188 + return; 189 + } 190 + 191 + if (embedData[r'$type'] == 'app.bsky.embed.record#viewRecord') { 192 + if (embedData['uri'] == null || 193 + embedData['cid'] == null || 194 + embedData['author'] == null || 195 + embedData['value'] == null || 196 + embedData['indexedAt'] == null) { 197 + postViewJson.remove('embed'); 198 + return; 199 + } 200 + } 201 + 202 + // Recursively check nested structures 203 + embedData.forEach((key, value) { 204 + if (value is Map<String, dynamic>) { 205 + _validateRecordViewInEmbed(value, postViewJson); 206 + } else if (value is List) { 207 + for (var i = 0; i < value.length; i++) { 208 + if (value[i] is Map<String, dynamic>) { 209 + _validateRecordViewInEmbed(value[i] as Map<String, dynamic>, postViewJson); 210 + } 211 + } 212 + } 213 + }); 214 + } 215 + 216 + // ============================================================================ 18 217 // Bluesky -> Spark Conversions 19 218 // ============================================================================ 20 219 /// Transforms Bluesky images (multiple) to Spark single image format ··· 252 451 ); 253 452 } 254 453 454 + // ============================================================================ 455 + // Bluesky Thread Conversion 456 + // ============================================================================ 457 + 458 + /// Convert a Bluesky parent thread to Spark Thread 459 + Thread? _convertParentToThread(bsky_defs.UThreadViewPostParent parent, AtUri uri) { 460 + switch (parent) { 461 + case bsky_defs.UThreadViewPostParentThreadViewPost(:final data): 462 + return convertBskyThreadToSparkThread( 463 + thread: UFeedGetPostThreadThread.threadViewPost(data: data), 464 + uri: uri, 465 + ); 466 + case bsky_defs.UThreadViewPostParentNotFoundPost(:final data): 467 + return Thread.notFoundPost(uri: data.uri, notFound: true); 468 + case bsky_defs.UThreadViewPostParentBlockedPost(:final data): 469 + return Thread.blockedPost(uri: data.uri, blocked: true, author: BlockedAuthor.fromJson(data.author.toJson())); 470 + case bsky_defs.UThreadViewPostParentUnknown(): 471 + return null; 472 + } 473 + } 474 + 255 475 /// Convert Bluesky thread to Spark thread 476 + /// 477 + /// This is the main entry point for converting Bluesky thread responses 478 + /// to Spark Thread models. Handles all thread types: normal posts, 479 + /// not found posts, and blocked posts. 256 480 Thread convertBskyThreadToSparkThread({ 257 481 required UFeedGetPostThreadThread thread, 258 482 required AtUri uri, 259 483 }) { 260 - return Thread.fromBsky(thread: thread, uri: uri); 484 + switch (thread) { 485 + case UFeedGetPostThreadThreadThreadViewPost(:final data): 486 + try { 487 + var embed = data.post.embed; 488 + if (data.post.embed is bsky_defs.UPostViewEmbedEmbedExternalView) { 489 + embed = null; 490 + } 491 + final postJson = data.post.copyWith(embed: embed); 492 + 493 + // Create PostView with deep copy - required because we modify nested structures like embeds 494 + final postViewJson = deepCopyJson(postJson.toJson()); 495 + 496 + // Ensure required fields are not null 497 + if (postViewJson['cid'] == null) { 498 + throw Exception('Post cid is null'); 499 + } 500 + if (postViewJson['uri'] == null) { 501 + throw Exception('Post uri is null'); 502 + } 503 + if (postViewJson['author'] == null) { 504 + throw Exception('Post author is null'); 505 + } 506 + if (postViewJson['record'] == null) { 507 + throw Exception('Post record is null'); 508 + } 509 + if (postViewJson['indexedAt'] == null) { 510 + throw Exception('Post indexedAt is null'); 511 + } 512 + 513 + // Ensure author required fields are not null 514 + final authorJson = postViewJson['author'] as Map<String, dynamic>; 515 + if (authorJson['did'] == null) { 516 + throw Exception('Author did is null'); 517 + } 518 + if (authorJson['handle'] == null) { 519 + throw Exception('Author handle is null'); 520 + } 521 + 522 + // Sanitize embeds using the adapter (handles all Bluesky-specific filtering) 523 + sanitizeBskyPostViewJson(postViewJson); 524 + 525 + // Convert from Bluesky format to Spark format 526 + convertPostViewJson(postViewJson); 527 + 528 + final sparkThread = Thread.threadViewPost( 529 + post: ThreadPost.post(post: PostView.fromJson(postViewJson)), 530 + parent: data.parent != null ? _convertParentToThread(data.parent!, uri) : null, 531 + replies: data.replies 532 + ?.map((reply) { 533 + switch (reply) { 534 + case bsky_defs.UThreadViewPostRepliesThreadViewPost(:final data): 535 + // Filter out replies with EmbedViewRecord using the adapter 536 + if (shouldFilterReply(data.post)) { 537 + return null; 538 + } 539 + return convertBskyThreadToSparkThread( 540 + thread: UFeedGetPostThreadThread.threadViewPost(data: data), 541 + uri: data.post.uri, 542 + ); 543 + case bsky_defs.UThreadViewPostRepliesNotFoundPost(:final data): 544 + return Thread.notFoundPost(uri: data.uri, notFound: true); 545 + case bsky_defs.UThreadViewPostRepliesBlockedPost(:final data): 546 + return Thread.blockedPost( 547 + uri: data.uri, 548 + blocked: true, 549 + author: BlockedAuthor.fromJson(data.author.toJson()), 550 + ); 551 + case bsky_defs.UThreadViewPostRepliesUnknown(): 552 + // Skip unknown reply types by returning null 553 + return null; 554 + } 555 + }) 556 + .whereType<Thread>() 557 + .toList(), 558 + ); 559 + return sparkThread; 560 + } catch (e) { 561 + rethrow; 562 + } 563 + case UFeedGetPostThreadThreadNotFoundPost(:final data): 564 + return Thread.notFoundPost(uri: data.uri, notFound: true); 565 + case UFeedGetPostThreadThreadBlockedPost(:final data): 566 + return Thread.blockedPost(uri: data.uri, blocked: true, author: BlockedAuthor.fromJson(data.author.toJson())); 567 + default: 568 + throw Exception('Unsupported thread type: ${thread.runtimeType}'); 569 + } 570 + } 571 + 572 + // ============================================================================ 573 + // Bluesky Feed Processing 574 + // ============================================================================ 575 + 576 + /// Check if a FeedViewPost has supported media 577 + bool _feedViewPostHasMedia(FeedViewPost feedViewPost) { 578 + return feedViewPost.map( 579 + post: (p) => p.post.hasSupportedMedia, 580 + reply: (r) => r.reply.media != null, 581 + ); 582 + } 583 + 584 + /// Check if a FeedViewPost is a reply 585 + bool _feedViewPostIsReply(FeedViewPost feedViewPost) { 586 + return feedViewPost.map( 587 + post: (p) => p.reply != null, 588 + reply: (r) => true, 589 + ); 590 + } 591 + 592 + /// Check if a raw Bluesky FeedViewPost is a repost 593 + /// Bluesky reposts have a "reason" field with type "app.bsky.feed.defs#reasonRepost" 594 + bool _isBskyRepost(bsky_defs.FeedViewPost feedViewPost) { 595 + return feedViewPost.reason != null; 596 + } 597 + 598 + /// Process raw Bluesky FeedViewPost list and convert to Spark format 599 + /// Handles: deep copy, conversion, parsing, and filtering (reposts, replies + media) 600 + ({List<FeedViewPost> posts, String? cursor}) processBskyAuthorFeed({ 601 + required List<bsky_defs.FeedViewPost> rawFeed, 602 + required String? cursor, 603 + void Function(String message, {Object? error, StackTrace? stackTrace})? onError, 604 + }) { 605 + final feedPosts = <FeedViewPost>[]; 606 + 607 + for (var i = 0; i < rawFeed.length; i++) { 608 + try { 609 + // Filter out reposts - Bluesky doesn't have a getReposts equivalent endpoint 610 + if (_isBskyRepost(rawFeed[i])) { 611 + continue; 612 + } 613 + 614 + // Deep copy to make mutable 615 + final rawPost = deepCopyJson(rawFeed[i].toJson()); 616 + 617 + // Convert using adapter 618 + convertFeedViewPostJson(rawPost); 619 + 620 + if (!rawPost.containsKey(r'$type')) { 621 + continue; 622 + } 623 + 624 + final parsedPost = FeedViewPost.fromJson(rawPost); 625 + feedPosts.add(parsedPost); 626 + } catch (e, stackTrace) { 627 + onError?.call('Failed to parse bsky feed view #$i', error: e, stackTrace: stackTrace); 628 + } 629 + } 630 + 631 + // Convert to Spark format and filter 632 + final convertedPosts = feedPosts.map((post) => post.toSparkFeedViewPost()).toList(); 633 + final filteredPosts = convertedPosts.where((post) => !_feedViewPostIsReply(post)).where(_feedViewPostHasMedia).toList(); 634 + 635 + return (posts: filteredPosts, cursor: cursor); 636 + } 637 + 638 + /// Check if a raw Bluesky feed item JSON is a repost 639 + bool _isBskyRepostJson(Map<String, dynamic> itemMap) { 640 + return itemMap.containsKey('reason') && itemMap['reason'] != null; 641 + } 642 + 643 + /// Process raw Bluesky feed item list (from getFeed API) and convert to Spark format 644 + /// Handles: conversion, parsing, and filtering (reposts, replies + media) 645 + List<FeedViewPost> processBskyFeedItems({ 646 + required List<dynamic> feedData, 647 + void Function(String message, {StackTrace? stackTrace})? onWarning, 648 + }) { 649 + final feedPosts = <FeedViewPost>[]; 650 + 651 + for (final item in feedData) { 652 + try { 653 + final itemMap = item as Map<String, dynamic>; 654 + 655 + // Filter out reposts - Bluesky doesn't have a getReposts equivalent endpoint 656 + if (_isBskyRepostJson(itemMap)) { 657 + continue; 658 + } 659 + 660 + // Convert the JSON to Spark format 661 + convertFeedViewPostJson(itemMap); 662 + 663 + // Ensure $type is set for FeedViewPost union type 664 + if (!itemMap.containsKey(r'$type')) { 665 + if (itemMap.containsKey('post')) { 666 + itemMap[r'$type'] = 'so.sprk.feed.defs#feedPostView'; 667 + } else if (itemMap.containsKey('reply')) { 668 + itemMap[r'$type'] = 'so.sprk.feed.defs#feedReplyView'; 669 + } 670 + } 671 + 672 + // Parse and convert 673 + final feedViewPost = FeedViewPost.fromJson(itemMap); 674 + final convertedPost = feedViewPost.toSparkFeedViewPost(); 675 + feedPosts.add(convertedPost); 676 + } catch (e, stackTrace) { 677 + onWarning?.call('Failed to parse feed item, skipping: $e', stackTrace: stackTrace); 678 + } 679 + } 680 + 681 + // Filter out replies and posts without media 682 + return feedPosts.where((post) => !_feedViewPostIsReply(post)).where(_feedViewPostHasMedia).toList(); 683 + } 684 + 685 + /// Process raw Bluesky PostView list and convert to Spark format 686 + /// Handles: deep copy, conversion, parsing, and optional filtering 687 + List<PostView> processBskyPosts({ 688 + required List<bsky_defs.PostView> rawPosts, 689 + bool filterByMedia = true, 690 + }) { 691 + final rawPostsJson = rawPosts.map((post) { 692 + final json = post.toJson(); 693 + return deepCopyJson(json); 694 + }).toList(); 695 + 696 + // Convert each post 697 + for (final rawPost in rawPostsJson) { 698 + convertPostViewJson(rawPost); 699 + } 700 + 701 + // Parse and convert to Spark format 702 + final parsedPosts = rawPostsJson.map(PostView.fromJson).toList(); 703 + final sparkPosts = parsedPosts.map((post) => post.toSparkPostView()).toList(); 704 + 705 + // Optionally filter by media 706 + if (filterByMedia) { 707 + return sparkPosts.where((post) => post.hasSupportedMedia).toList(); 708 + } 709 + return sparkPosts; 261 710 } 262 711 } 263 712
+72
lib/src/core/network/atproto/data/adapters/bsky/repo_adapter.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + 3 + /// Callback type for deleting a record 4 + typedef DeleteRecordCallback = Future<void> Function({ 5 + required String repo, 6 + required String collection, 7 + required String rkey, 8 + }); 9 + 10 + /// Adapter for Bluesky repository operations 11 + /// 12 + /// Handles Bluesky-specific repository operations like cross-post management 13 + /// and URI conversions between Spark and Bluesky namespaces. 14 + class BskyRepoAdapter { 15 + const BskyRepoAdapter(); 16 + 17 + // ============================================================================ 18 + // Bluesky Cross-Post Handling 19 + // ============================================================================ 20 + 21 + /// Build a Bluesky counterpart URI from a Spark post URI 22 + /// 23 + /// Converts `at://did/so.sprk.feed.post/rkey` to `at://did/app.bsky.feed.post/rkey` 24 + AtUri buildBlueskyCounterpartUri(AtUri sparkUri) { 25 + final did = sparkUri.hostname; 26 + final rkey = sparkUri.rkey; 27 + return AtUri.parse('at://$did/app.bsky.feed.post/$rkey'); 28 + } 29 + 30 + /// Delete the Bluesky counterpart of a Spark post 31 + /// 32 + /// This is a best-effort operation - errors are caught and returned as false. 33 + /// Returns true if deletion was successful, false otherwise. 34 + /// 35 + /// [deleteRecord] is a callback that performs the actual deletion (typically atproto.repo.deleteRecord) 36 + Future<bool> deleteBlueskyCounterpart( 37 + DeleteRecordCallback deleteRecord, 38 + AtUri sparkUri, 39 + ) async { 40 + try { 41 + final blueskyUri = buildBlueskyCounterpartUri(sparkUri); 42 + await deleteRecord( 43 + repo: blueskyUri.hostname, 44 + collection: blueskyUri.collection.toString(), 45 + rkey: blueskyUri.rkey, 46 + ); 47 + return true; 48 + } catch (e) { 49 + // Ignore errors like 404 – it simply means the counterpart does not exist. 50 + return false; 51 + } 52 + } 53 + 54 + /// Check if a URI is a Bluesky feed post 55 + bool isBlueskyFeedPost(AtUri uri) { 56 + return uri.collection.toString() == 'app.bsky.feed.post'; 57 + } 58 + 59 + /// Check if a URI is a Spark feed post 60 + bool isSparkFeedPost(AtUri uri) { 61 + return uri.collection.toString() == 'so.sprk.feed.post'; 62 + } 63 + } 64 + 65 + /// Singleton instance of the Bluesky repo adapter 66 + /// 67 + /// Use this instance for all Bluesky repository operations: 68 + /// ```dart 69 + /// final blueskyUri = bskyRepoAdapter.buildBlueskyCounterpartUri(sparkUri); 70 + /// await bskyRepoAdapter.deleteBlueskyCounterpart(repoService, sparkUri); 71 + /// ``` 72 + const bskyRepoAdapter = BskyRepoAdapter();
+16 -208
lib/src/core/network/atproto/data/models/feed_models.dart
··· 1 1 import 'package:atproto/com_atproto_label_defs.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 - import 'package:bluesky/app_bsky_feed_defs.dart' as bsky_defs; 4 3 import 'package:bluesky/app_bsky_feed_getpostthread.dart'; 5 4 import 'package:flutter/foundation.dart'; 6 5 import 'package:freezed_annotation/freezed_annotation.dart'; 7 6 import 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 8 7 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 9 - import 'package:sparksocial/src/core/utils/json_utils.dart'; 10 8 import 'package:sparksocial/src/core/utils/uri_converter.dart'; 11 9 12 10 part 'feed_models.freezed.dart'; ··· 592 590 required BlockedAuthor author, 593 591 }) = EmbedViewRecord_Blocked; 594 592 595 - factory EmbedViewRecord.fromJson(Map<String, dynamic> json) => _$EmbedViewRecordFromJson(json); 593 + factory EmbedViewRecord.fromJson(Map<String, dynamic> json) { 594 + // Handle unsupported record embed types by returning notFound (uses adapter's list) 595 + final type = json[r'$type'] as String?; 596 + if (BskyFeedAdapter.isUnsupportedEmbedType(type)) { 597 + return EmbedViewRecord.notFound( 598 + uri: AtUri.parse(json['uri'] as String? ?? 'at://unknown'), 599 + notFound: true, 600 + ); 601 + } 602 + return _$EmbedViewRecordFromJson(json); 603 + } 596 604 } 597 605 598 606 @freezed ··· 832 840 833 841 factory Thread.fromJson(Map<String, dynamic> json) => _$ThreadFromJson(json); 834 842 835 - static Thread? _convertParentToThread(bsky_defs.UThreadViewPostParent parent, AtUri uri) { 836 - switch (parent) { 837 - case bsky_defs.UThreadViewPostParentThreadViewPost(:final data): 838 - return Thread.fromBsky( 839 - thread: UFeedGetPostThreadThread.threadViewPost(data: data), 840 - uri: uri, 841 - ); 842 - case bsky_defs.UThreadViewPostParentNotFoundPost(:final data): 843 - return Thread.notFoundPost(uri: data.uri, notFound: true); 844 - case bsky_defs.UThreadViewPostParentBlockedPost(:final data): 845 - return Thread.blockedPost(uri: data.uri, blocked: true, author: BlockedAuthor.fromJson(data.author.toJson())); 846 - case bsky_defs.UThreadViewPostParentUnknown(): 847 - return null; 848 - } 849 - } 850 - 843 + /// Convert a Bluesky thread to Spark Thread format 844 + /// 845 + /// Delegates to [bskyFeedAdapter.convertBskyThreadToSparkThread] which handles 846 + /// all Bluesky-specific conversion logic. 851 847 factory Thread.fromBsky({required UFeedGetPostThreadThread thread, required AtUri uri}) { 852 - switch (thread) { 853 - case UFeedGetPostThreadThreadThreadViewPost(:final data): 854 - try { 855 - var embed = data.post.embed; 856 - if (data.post.embed is bsky_defs.UPostViewEmbedEmbedExternalView) { 857 - embed = null; 858 - } 859 - final postJson = data.post.copyWith(embed: embed); 860 - 861 - // Create PostView with deep copy - required because we modify nested structures like embeds 862 - final postViewJson = deepCopyJson(postJson.toJson()); 863 - 864 - // Ensure required fields are not null 865 - if (postViewJson['cid'] == null) { 866 - throw Exception('Post cid is null'); 867 - } 868 - if (postViewJson['uri'] == null) { 869 - throw Exception('Post uri is null'); 870 - } 871 - if (postViewJson['author'] == null) { 872 - throw Exception('Post author is null'); 873 - } 874 - if (postViewJson['record'] == null) { 875 - throw Exception('Post record is null'); 876 - } 877 - if (postViewJson['indexedAt'] == null) { 878 - throw Exception('Post indexedAt is null'); 879 - } 880 - 881 - // Ensure author required fields are not null 882 - final authorJson = postViewJson['author'] as Map<String, dynamic>; 883 - if (authorJson['did'] == null) { 884 - throw Exception('Author did is null'); 885 - } 886 - if (authorJson['handle'] == null) { 887 - throw Exception('Author handle is null'); 888 - } 889 - 890 - // Check embed data if present - this is where the error is occurring 891 - if (postViewJson['embed'] != null) { 892 - final embedJson = postViewJson['embed'] as Map<String, dynamic>; 893 - 894 - // Check for external embed without required cid 895 - if (embedJson[r'$type'] == 'app.bsky.embed.external#view') { 896 - if (embedJson['cid'] == null) { 897 - postViewJson.remove('embed'); 898 - } 899 - } 900 - 901 - // If it's a record embed, check the record data 902 - if (embedJson[r'$type'] == 'app.bsky.embed.record#view' && embedJson['record'] != null) { 903 - final recordJson = embedJson['record'] as Map<String, dynamic>; 904 - 905 - // Check required fields for EmbedViewBskyRecordViewRecord 906 - if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 907 - if (recordJson['cid'] == null) { 908 - postViewJson.remove('embed'); 909 - } 910 - if (recordJson['uri'] == null) { 911 - postViewJson.remove('embed'); 912 - } 913 - if (recordJson['author'] == null) { 914 - postViewJson.remove('embed'); 915 - } 916 - if (recordJson['value'] == null) { 917 - postViewJson.remove('embed'); 918 - } 919 - if (recordJson['indexedAt'] == null) { 920 - postViewJson.remove('embed'); 921 - } 922 - 923 - // Check nested embeds array in the record value 924 - if (recordJson['embeds'] != null && recordJson['embeds'] is List) { 925 - final embedsList = recordJson['embeds'] as List; 926 - var shouldRemoveEmbed = false; 927 - 928 - for (final nestedEmbed in embedsList) { 929 - if (nestedEmbed is Map<String, dynamic>) { 930 - // Check external embeds in the nested embeds 931 - if (nestedEmbed[r'$type'] == 'app.bsky.embed.external#view' && nestedEmbed['cid'] == null) { 932 - shouldRemoveEmbed = true; 933 - break; 934 - } 935 - } 936 - } 937 - 938 - if (shouldRemoveEmbed) { 939 - postViewJson.remove('embed'); 940 - } 941 - } 942 - } 943 - } 944 - 945 - // Enhanced check for recordWithMedia embeds 946 - if (embedJson[r'$type'] == 'app.bsky.embed.recordWithMedia#view') { 947 - // Check the record part 948 - if (embedJson['record'] != null) { 949 - final recordEmbedJson = embedJson['record'] as Map<String, dynamic>; 950 - if (recordEmbedJson['record'] != null) { 951 - final recordJson = recordEmbedJson['record'] as Map<String, dynamic>; 952 - 953 - // Check if it's a viewRecord and has required fields 954 - if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 955 - if (recordJson['uri'] == null || 956 - recordJson['cid'] == null || 957 - recordJson['author'] == null || 958 - recordJson['value'] == null || 959 - recordJson['indexedAt'] == null) { 960 - postViewJson.remove('embed'); 961 - } 962 - } 963 - } 964 - } 965 - } 966 - 967 - // Additional safety check - if we have any embed that might contain a record view, validate it 968 - void validateRecordViewInEmbed(Map<String, dynamic> embedData, String path) { 969 - if (embedData[r'$type'] == 'app.bsky.embed.record#viewRecord') { 970 - if (embedData['uri'] == null || 971 - embedData['cid'] == null || 972 - embedData['author'] == null || 973 - embedData['value'] == null || 974 - embedData['indexedAt'] == null) { 975 - postViewJson.remove('embed'); 976 - return; 977 - } 978 - } 979 - 980 - // Recursively check nested structures 981 - embedData.forEach((key, value) { 982 - if (value is Map<String, dynamic>) { 983 - validateRecordViewInEmbed(value, '$path.$key'); 984 - } else if (value is List) { 985 - for (var i = 0; i < value.length; i++) { 986 - if (value[i] is Map<String, dynamic>) { 987 - validateRecordViewInEmbed(value[i] as Map<String, dynamic>, '$path.$key[$i]'); 988 - } 989 - } 990 - } 991 - }); 992 - } 993 - 994 - // Run the validation on the entire embed structure 995 - if (postViewJson['embed'] != null) { 996 - validateRecordViewInEmbed(postViewJson['embed'] as Map<String, dynamic>, 'embed'); 997 - } 998 - } 999 - 1000 - // Convert from Bluesky format to Spark format 1001 - bskyFeedAdapter.convertPostViewJson(postViewJson); 1002 - 1003 - final thread = Thread.threadViewPost( 1004 - post: ThreadPost.post(post: PostView.fromJson(postViewJson)), 1005 - parent: data.parent != null ? _convertParentToThread(data.parent!, uri) : null, 1006 - replies: data.replies 1007 - ?.map((reply) { 1008 - switch (reply) { 1009 - case bsky_defs.UThreadViewPostRepliesThreadViewPost(:final data): 1010 - return Thread.fromBsky( 1011 - thread: UFeedGetPostThreadThread.threadViewPost(data: data), 1012 - uri: data.post.uri, 1013 - ); 1014 - case bsky_defs.UThreadViewPostRepliesNotFoundPost(:final data): 1015 - return Thread.notFoundPost(uri: data.uri, notFound: true); 1016 - case bsky_defs.UThreadViewPostRepliesBlockedPost(:final data): 1017 - return Thread.blockedPost( 1018 - uri: data.uri, 1019 - blocked: true, 1020 - author: BlockedAuthor.fromJson(data.author.toJson()), 1021 - ); 1022 - case bsky_defs.UThreadViewPostRepliesUnknown(): 1023 - // Skip unknown reply types by returning null 1024 - return null; 1025 - } 1026 - }) 1027 - .whereType<Thread>() 1028 - .toList(), 1029 - ); 1030 - return thread; 1031 - } catch (e) { 1032 - rethrow; 1033 - } 1034 - case UFeedGetPostThreadThreadNotFoundPost(:final data): 1035 - return Thread.notFoundPost(uri: data.uri, notFound: true); 1036 - case UFeedGetPostThreadThreadBlockedPost(:final data): 1037 - return Thread.blockedPost(uri: data.uri, blocked: true, author: BlockedAuthor.fromJson(data.author.toJson())); 1038 - default: 1039 - throw Exception('Unsupported thread type: ${thread.runtimeType}'); 1040 - } 848 + return bskyFeedAdapter.convertBskyThreadToSparkThread(thread: thread, uri: uri); 1041 849 } 1042 850 1043 851 factory Thread.fromSparkFlatList({required List<dynamic> threadItems}) {
+5 -4
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 4 4 import 'package:bluesky/bluesky.dart' as bsky; 5 5 import 'package:get_it/get_it.dart'; 6 6 import 'package:http/http.dart' as http; 7 + import 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/actor_adapter.dart'; 7 8 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 8 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 9 10 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; ··· 46 47 ..e('Failed to retrieve profile for DID: $did', error: e) 47 48 ..i('Trying to get profile from bluesky'); 48 49 final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 49 - final profile = await bluesky.actor.getProfile(actor: did); 50 + final profile = await bskyActorAdapter.getProfileFromBluesky(bluesky, did); 50 51 _logger.d('Profile retrieved successfully from bluesky'); 51 - return ProfileViewDetailed.fromJson(profile.toJson()); 52 + return profile; 52 53 } 53 54 }); 54 55 } ··· 167 168 ..e('Failed to retrieve profile for DIDs: $dids', error: e) 168 169 ..i('Trying to get profiles from bluesky'); 169 170 final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 170 - final profiles = await bluesky.actor.getProfiles(actors: dids); 171 + final profiles = await bskyActorAdapter.getProfilesFromBluesky(bluesky, dids); 171 172 _logger.d('Profiles retrieved successfully from bluesky'); 172 - return profiles.data.profiles.map((p) => ProfileViewDetailed.fromJson(p.toJson())).toList(); 173 + return profiles; 173 174 } 174 175 }); 175 176 }
+27 -83
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 18 18 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 19 19 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 20 20 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 21 - import 'package:sparksocial/src/core/utils/json_utils.dart'; 22 21 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 23 22 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 24 23 ··· 43 42 return feedViewPost.map( 44 43 post: (p) => p.post.hasSupportedMedia, 45 44 reply: (r) => r.reply.media != null, 46 - ); 47 - } 48 - 49 - bool _feedViewPostIsReply(FeedViewPost feedViewPost) { 50 - return feedViewPost.map( 51 - post: (p) => p.reply != null, // If post has reply field, it's a reply 52 - reply: (r) => true, // Reply variant is always a reply 53 45 ); 54 46 } 55 47 ··· 125 117 final blueskyClient = bsky.Bluesky.fromSession(_client.authRepository.session!); 126 118 final posts = await blueskyClient.feed.getPosts(uris: uris); 127 119 128 - // Convert Bluesky posts using adapter 129 - // Create mutable copies to avoid "Cannot modify unmodifiable map" errors 130 - final rawPosts = posts.data.posts.map((post) { 131 - final json = post.toJson(); 132 - return deepCopyJson(json); 133 - }).toList(); 134 - 135 - rawPosts.forEach(bskyFeedAdapter.convertPostViewJson); 136 - 137 - final parsedPosts = rawPosts.map(PostView.fromJson).toList(); 138 - final sparkPosts = parsedPosts.map((post) => post.toSparkPostView()).toList(); 139 - final filteredPosts = filter ? sparkPosts.where(_postViewHasMedia).toList() : sparkPosts; 140 - return filteredPosts; 120 + // Use adapter to process Bluesky posts 121 + return bskyFeedAdapter.processBskyPosts( 122 + rawPosts: posts.data.posts, 123 + filterByMedia: filter, 124 + ); 141 125 } 142 126 return _client.executeWithRetry(() async { 143 127 if (!_client.authRepository.isAuthenticated) { ··· 274 258 filter: FeedGetAuthorFeedFilter.valueOf(videosOnly ? 'posts_with_video' : 'posts_with_media'), 275 259 ); 276 260 277 - // Create mutable copies of the JSON maps since toJson() returns unmodifiable maps 278 - final rawFeed = resultBsky.data.feed.map((feedView) { 279 - final json = feedView.toJson(); 280 - // Deep copy to make it mutable 281 - return deepCopyJson(json); 282 - }).toList(); 283 - 284 - // Use adapter to convert Bluesky JSON to Spark structure 285 - final feedPosts = <FeedViewPost>[]; 286 - for (var i = 0; i < rawFeed.length; i++) { 287 - try { 288 - final rawPost = rawFeed[i]; 289 - bskyFeedAdapter.convertFeedViewPostJson(rawPost); 290 - 291 - if (!rawPost.containsKey(r'$type')) { 292 - continue; 293 - } 294 - 295 - final parsedPost = FeedViewPost.fromJson(rawPost); 296 - feedPosts.add(parsedPost); 297 - } catch (e, stackTrace) { 298 - _logger.e('Failed to parse bsky feed view #$i', error: e, stackTrace: stackTrace); 299 - } 300 - } 301 - final convertedPosts = feedPosts.map((post) => post.toSparkFeedViewPost()).toList(); 302 - // Filter out replies for Bluesky feeds (Spark posts can't be replies) 303 - final filteredPosts = convertedPosts.where((post) => !_feedViewPostIsReply(post)).where(_feedViewPostHasMedia).toList(); 304 - return (posts: filteredPosts, cursor: resultBsky.data.cursor); 261 + // Use adapter to process Bluesky author feed 262 + return bskyFeedAdapter.processBskyAuthorFeed( 263 + rawFeed: resultBsky.data.feed, 264 + cursor: resultBsky.data.cursor, 265 + onError: (message, {error, stackTrace}) => _logger.e(message, error: error, stackTrace: stackTrace), 266 + ); 305 267 } catch (e) { 306 268 _logger.e('Error getting author feed from Bsky', error: e); 307 269 rethrow; ··· 436 398 return const FeedView(feed: []); 437 399 } 438 400 439 - final feedPosts = <FeedViewPost>[]; 440 - for (final item in feedData) { 441 - try { 442 - final itemMap = item as Map<String, dynamic>; 401 + List<FeedViewPost> feedPosts; 443 402 444 - // For Bluesky feeds, convert the JSON to Spark format using adapter 445 - if (isBskyFeed) { 446 - bskyFeedAdapter.convertFeedViewPostJson(itemMap); 447 - 448 - // Ensure $type is set for FeedViewPost union type 449 - if (!itemMap.containsKey(r'$type')) { 450 - if (itemMap.containsKey('post')) { 451 - itemMap[r'$type'] = 'so.sprk.feed.defs#feedPostView'; 452 - } else if (itemMap.containsKey('reply')) { 453 - itemMap[r'$type'] = 'so.sprk.feed.defs#feedReplyView'; 454 - } 455 - } 456 - 457 - // Parse the FeedViewPost 458 - final feedViewPost = FeedViewPost.fromJson(itemMap); 459 - final convertedPost = feedViewPost.toSparkFeedViewPost(); 460 - feedPosts.add(convertedPost); 461 - } else { 462 - // Spark feeds: The response has a 'post' object containing the fully hydrated post view 403 + if (isBskyFeed) { 404 + // Use adapter to process Bluesky feed items 405 + feedPosts = bskyFeedAdapter.processBskyFeedItems( 406 + feedData: feedData, 407 + onWarning: _logger.w, 408 + ); 409 + } else { 410 + // Spark feeds: parse directly 411 + feedPosts = <FeedViewPost>[]; 412 + for (final item in feedData) { 413 + try { 414 + final itemMap = item as Map<String, dynamic>; 463 415 final postMap = itemMap['post'] as Map<String, dynamic>?; 464 416 if (postMap == null) { 465 417 continue; 466 418 } 467 419 468 - // Parse the post view 469 420 final postView = PostView.fromJson(postMap); 470 - 471 - // Create a FeedViewPost.post variant (not a reply) 472 421 final feedViewPost = FeedViewPost.post(post: postView); 473 422 feedPosts.add(feedViewPost); 423 + } catch (e, stackTrace) { 424 + _logger.w('Failed to parse feed item, skipping: $e', stackTrace: stackTrace); 474 425 } 475 - } catch (e, stackTrace) { 476 - _logger.w('Failed to parse feed item, skipping: $e', stackTrace: stackTrace); 477 426 } 478 427 } 479 428 480 - // Filter out replies for Bluesky feeds (Spark posts can't be replies) 481 - final filteredPosts = isBskyFeed 482 - ? feedPosts.where((post) => !_feedViewPostIsReply(post)).where(_feedViewPostHasMedia).toList() 483 - : feedPosts; 484 - 485 429 return FeedView( 486 - feed: filteredPosts, 430 + feed: feedPosts, 487 431 cursor: jsonMap['cursor'] as String?, 488 432 ); 489 433 },
+12 -19
lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart
··· 5 5 import 'package:atproto/com_atproto_services.dart'; 6 6 import 'package:atproto/core.dart'; 7 7 import 'package:get_it/get_it.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/data/adapters/bsky/repo_adapter.dart'; 8 9 import 'package:sparksocial/src/core/network/atproto/data/models/record_models.dart'; 9 10 import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 10 11 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; ··· 108 109 109 110 // Delete cross-posted Bluesky counterpart if it exists (only for posts) 110 111 if (!skipBskyCrosspostCleanup) { 111 - try { 112 - final did = uri.hostname; 113 - final rkey = uri.rkey; 114 - final blueskyUri = AtUri.parse('at://$did/app.bsky.feed.post/$rkey'); 112 + final blueskyUri = bskyRepoAdapter.buildBlueskyCounterpartUri(uri); 113 + _logger.d('Attempting to delete Bluesky counterpart post: $blueskyUri'); 115 114 116 - _logger.d('Attempting to delete Bluesky counterpart post: $blueskyUri'); 115 + final deleted = await bskyRepoAdapter.deleteBlueskyCounterpart( 116 + ({required repo, required collection, required rkey}) => 117 + atproto.repo.deleteRecord(repo: repo, collection: collection, rkey: rkey), 118 + uri, 119 + ); 117 120 118 - try { 119 - await atproto.repo.deleteRecord( 120 - repo: blueskyUri.hostname, 121 - collection: blueskyUri.collection.toString(), 122 - rkey: blueskyUri.rkey, 123 - ); 124 - _logger.d('Bluesky counterpart post deleted successfully'); 125 - } catch (e) { 126 - // Ignore errors like 404 – it simply means the counterpart does not exist. 127 - _logger.w('Bluesky counterpart post not found or deletion failed', error: e); 128 - } 129 - } catch (e) { 130 - // Best-effort only – do not fail original deletion. 131 - _logger.w('Failed during Bluesky cross-post deletion cleanup', error: e); 121 + if (deleted) { 122 + _logger.d('Bluesky counterpart post deleted successfully'); 123 + } else { 124 + _logger.w('Bluesky counterpart post not found or deletion failed'); 132 125 } 133 126 } 134 127 });
+2 -27
lib/src/features/comments/providers/comment_provider.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 2 - import 'package:bluesky/com_atproto_repo_strongref.dart'; 3 1 import 'package:cached_network_image/cached_network_image.dart'; 4 2 import 'package:flutter/material.dart'; 5 3 import 'package:get_it/get_it.dart'; 6 - import 'package:image_picker/image_picker.dart'; 7 4 import 'package:riverpod_annotation/riverpod_annotation.dart'; 8 5 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 9 6 import 'package:sparksocial/src/features/comments/providers/comment_state.dart'; ··· 13 10 14 11 @Riverpod(keepAlive: true) 15 12 class CommentNotifier extends _$CommentNotifier { 13 + FeedRepository get _feedRepository => GetIt.instance<SprkRepository>().feed; 14 + 16 15 @override 17 16 CommentState build(Thread thread) { 18 - _feedRepository = GetIt.instance<SprkRepository>().feed; 19 17 switch (thread) { 20 18 case ThreadViewPost(): 21 19 return CommentState(thread: thread); ··· 25 23 throw Exception('Post is blocked'); 26 24 } 27 25 } 28 - 29 - late final FeedRepository _feedRepository; 30 26 31 27 Future<void> toggleLike() async { 32 28 final wasLiked = state.isLiked; ··· 94 90 } 95 91 } 96 92 } 97 - 98 - Future<RepoStrongRef> postComment( 99 - String text, 100 - String parentCid, 101 - String parentUri, { 102 - String? rootCid, 103 - String? rootUri, 104 - List<XFile>? imageFiles, 105 - Map<String, String>? altTexts, 106 - }) async { 107 - final feedRepository = GetIt.instance<SprkRepository>().feed; 108 - return feedRepository.postComment( 109 - text, 110 - parentCid, 111 - AtUri.parse(parentUri), 112 - rootCid: rootCid, 113 - rootUri: rootUri != null ? AtUri.parse(rootUri) : null, 114 - imageFiles: imageFiles, 115 - altTexts: altTexts, 116 - ); 117 - }
+36 -17
lib/src/features/comments/providers/comments_page_provider.dart
··· 10 10 11 11 @riverpod 12 12 class CommentsPage extends _$CommentsPage { 13 - late final FeedRepository feedRepository; 13 + FeedRepository get feedRepository => GetIt.instance<SprkRepository>().feed; 14 14 15 15 @override 16 16 Future<CommentsPageState> build({required AtUri postUri}) async { 17 - feedRepository = GetIt.instance<SprkRepository>().feed; 18 17 final isBlueskyPost = postUri.collection.toString().startsWith('app.bsky.feed.post'); 18 + const timeoutDuration = Duration(seconds: 30); 19 19 20 + // First attempt to get the thread directly with timeout 20 21 try { 21 - final thread = await feedRepository.getThread(postUri, bluesky: isBlueskyPost, depth: 1); 22 + final thread = await feedRepository 23 + .getThread(postUri, bluesky: isBlueskyPost, depth: 1) 24 + .timeout(timeoutDuration, onTimeout: () { 25 + throw Exception('Request timed out while loading thread for $postUri'); 26 + }); 22 27 switch (thread) { 23 28 case ThreadViewPost(): 24 29 return CommentsPageState(thread: thread); ··· 27 32 case BlockedPost(): 28 33 throw Exception('Post is blocked'); 29 34 } 30 - } catch (e) { 31 - final networkPost = await feedRepository.getPosts([postUri], bluesky: isBlueskyPost, filter: false); 32 - if (networkPost.isEmpty) { 33 - throw Exception('No posts found at $postUri'); 34 - } 35 + } catch (firstError) { 36 + // If getThread fails, verify the post exists and retry once with timeout 37 + try { 38 + final networkPost = await feedRepository 39 + .getPosts([postUri], bluesky: isBlueskyPost, filter: false) 40 + .timeout(timeoutDuration, onTimeout: () { 41 + throw Exception('Request timed out while verifying post exists'); 42 + }); 43 + if (networkPost.isEmpty) { 44 + throw Exception('No posts found at $postUri'); 45 + } 35 46 36 - final thread = await feedRepository.getThread(postUri, bluesky: isBlueskyPost, depth: 1); 37 - switch (thread) { 38 - case ThreadViewPost(): 39 - return CommentsPageState(thread: thread); 40 - case NotFoundPost(): 41 - throw Exception('Post not found'); 42 - case BlockedPost(): 43 - throw Exception('Post is blocked'); 47 + // Retry getThread once after confirming post exists 48 + final thread = await feedRepository 49 + .getThread(postUri, bluesky: isBlueskyPost, depth: 1) 50 + .timeout(timeoutDuration, onTimeout: () { 51 + throw Exception('Request timed out while retrying thread load for $postUri'); 52 + }); 53 + switch (thread) { 54 + case ThreadViewPost(): 55 + return CommentsPageState(thread: thread); 56 + case NotFoundPost(): 57 + throw Exception('Post not found'); 58 + case BlockedPost(): 59 + throw Exception('Post is blocked'); 60 + } 61 + } catch (_) { 62 + // Re-throw the original error to prevent infinite retry loops 63 + throw firstError; 44 64 } 45 65 } 46 66 } ··· 54 74 List<XFile>? imageFiles, 55 75 Map<String, String>? altTexts, 56 76 }) async { 57 - final feedRepository = GetIt.instance<SprkRepository>().feed; 58 77 59 78 // We need the current state to determine if the post is a sprk or bsky post. 60 79 // If the state is not loaded, we cannot proceed.
-2
lib/src/features/profile/ui/pages/profile_page.dart
··· 93 93 case 0: 94 94 // First tab - default profile grid content (not a route) 95 95 tabWidget = ProfileGridTab(profileUri: profileUri); 96 - break; 97 96 case 1: 98 97 // Second tab - reposts 99 98 tabWidget = ProfileRepostsTab(profileUri: profileUri); 100 - break; 101 99 default: 102 100 // Fallback to first tab 103 101 tabWidget = ProfileGridTab(profileUri: profileUri);