From 9b2eb3635e23a07fda42df08923bd2d798c31ad4 Mon Sep 17 00:00:00 2001 From: Owais Date: Wed, 13 May 2026 19:41:37 -0500 Subject: [PATCH] refactor: update Bluesky network layer to typed Poptart records (#47) * refactor: poptart services * refactor: poptart repositories * refactor: cubits & blocs * refactor: adapters to poptart * refactor: tests to poptart * refactor: convert json strings to freezed types * refactor: SavedPostsCubit -> public save paths no longer accept JSON strings * fix: atproto datetime serialization * feat: record roundtrip conversion --- lib/core/cache/poptart_cache_codecs.dart | 81 ++ lib/core/network/clients/poptart_clients.dart | 295 +++++++ lib/core/network/poptart_client_adapter.dart | 762 +++--------------- .../services/atproto_identity_service.dart | 20 + .../services/atproto_moderation_service.dart | 23 + .../services/atproto_repo_service.dart | 149 ++++ .../services/atproto_server_service.dart | 26 + .../services/bluesky_actor_service.dart | 74 ++ .../services/bluesky_bookmark_service.dart | 48 ++ .../services/bluesky_chat_services.dart | 54 ++ .../services/bluesky_feed_service.dart | 247 ++++++ .../services/bluesky_graph_service.dart | 195 +++++ .../services/bluesky_labeler_service.dart | 21 + .../bluesky_notification_service.dart | 89 ++ .../services/bluesky_unspecced_service.dart | 49 ++ .../services/bluesky_video_service.dart | 52 ++ .../services/current_repo_record_service.dart | 62 ++ .../services/feed_record_services.dart | 75 ++ .../services/graph_record_services.dart | 225 ++++++ .../network/services/poptart_helpers.dart | 105 +++ lib/core/scheduler/post_scheduler.dart | 84 +- .../auth/presentation/login_screen.dart | 10 +- lib/features/compose/bloc/compose_bloc.dart | 325 ++++---- .../compose/data/draft_embed_payload.dart | 80 ++ .../data/draft_embed_payload.freezed.dart | 322 ++++++++ .../devtools/cubit/dev_tools_cubit.dart | 2 +- .../feed/cubit/saved_posts_cubit.dart | 39 +- lib/features/feed/data/feed_repository.dart | 25 +- .../feed/data/liked_posts_repository.dart | 44 +- .../feed/data/post_action_repository.dart | 2 +- .../feed/data/post_thread_repository.dart | 24 +- .../feed/presentation/post_thread_screen.dart | 3 +- .../feed/presentation/saved_posts_screen.dart | 14 +- .../widgets/post_card_with_actions.dart | 5 +- lib/features/lists/bloc/list_bloc.dart | 4 +- lib/features/lists/cubit/my_lists_cubit.dart | 4 +- lib/features/lists/data/list_repository.dart | 62 +- .../moderation/data/moderation_service.dart | 31 +- .../notifications/bloc/notification_bloc.dart | 4 +- .../profile/data/follow_audit_repository.dart | 48 +- .../data/profile_context_repository.dart | 57 +- .../profile/data/profile_repository.dart | 187 +++-- .../search/data/search_repository.dart | 6 +- .../search/data/semantic_indexer.dart | 28 +- .../presentation/semantic_search_tab.dart | 8 +- .../data/starter_pack_repository.dart | 22 +- .../typeahead/data/typeahead_repository.dart | 11 +- lib/shared/utils/atproto_datetime.dart | 22 + .../core/cache/poptart_cache_codecs_test.dart | 64 ++ .../network/poptart_client_adapter_test.dart | 174 +++- .../compose/bloc/compose_bloc_test.dart | 96 +-- ...compose_repository_auth_recovery_test.dart | 146 ++-- .../data/draft_embed_payload_test.dart | 40 + .../feed/cubit/saved_posts_cubit_test.dart | 85 +- .../feed/data/feed_repository_cache_test.dart | 114 +-- .../data/feed_repository_fallback_test.dart | 7 +- .../data/liked_posts_repository_test.dart | 77 +- .../post_thread_repository_cache_test.dart | 63 +- test/features/lists/bloc/list_bloc_test.dart | 8 +- .../lists/cubit/my_lists_cubit_test.dart | 8 +- .../lists/data/list_repository_test.dart | 501 +++++------- .../data/moderation_service_test.dart | 68 +- .../data/follow_audit_repository_test.dart | 135 +++- .../data/profile_context_repository_test.dart | 142 +++- .../profile_repository_actor_likes_test.dart | 95 ++- .../profile/data/profile_repository_test.dart | 176 +++- .../data/search_repository_fallback_test.dart | 7 +- .../search_repository_post_filters_test.dart | 78 +- .../data/typeahead_repository_test.dart | 40 +- test/helpers/test_bluesky_client.dart | 41 + test/shared/utils/atproto_datetime_test.dart | 29 + 71 files changed, 4395 insertions(+), 1924 deletions(-) create mode 100644 lib/core/cache/poptart_cache_codecs.dart create mode 100644 lib/core/network/clients/poptart_clients.dart create mode 100644 lib/core/network/services/atproto_identity_service.dart create mode 100644 lib/core/network/services/atproto_moderation_service.dart create mode 100644 lib/core/network/services/atproto_repo_service.dart create mode 100644 lib/core/network/services/atproto_server_service.dart create mode 100644 lib/core/network/services/bluesky_actor_service.dart create mode 100644 lib/core/network/services/bluesky_bookmark_service.dart create mode 100644 lib/core/network/services/bluesky_chat_services.dart create mode 100644 lib/core/network/services/bluesky_feed_service.dart create mode 100644 lib/core/network/services/bluesky_graph_service.dart create mode 100644 lib/core/network/services/bluesky_labeler_service.dart create mode 100644 lib/core/network/services/bluesky_notification_service.dart create mode 100644 lib/core/network/services/bluesky_unspecced_service.dart create mode 100644 lib/core/network/services/bluesky_video_service.dart create mode 100644 lib/core/network/services/current_repo_record_service.dart create mode 100644 lib/core/network/services/feed_record_services.dart create mode 100644 lib/core/network/services/graph_record_services.dart create mode 100644 lib/core/network/services/poptart_helpers.dart create mode 100644 lib/features/compose/data/draft_embed_payload.dart create mode 100644 lib/features/compose/data/draft_embed_payload.freezed.dart create mode 100644 lib/shared/utils/atproto_datetime.dart create mode 100644 test/core/cache/poptart_cache_codecs_test.dart create mode 100644 test/features/compose/data/draft_embed_payload_test.dart create mode 100644 test/helpers/test_bluesky_client.dart create mode 100644 test/shared/utils/atproto_datetime_test.dart diff --git a/lib/core/cache/poptart_cache_codecs.dart b/lib/core/cache/poptart_cache_codecs.dart new file mode 100644 index 0000000..5b7756c --- /dev/null +++ b/lib/core/cache/poptart_cache_codecs.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:poptart_lex/app/bsky/labeler/defs.dart'; + +class JsonStringCacheCodec { + const JsonStringCacheCodec({required this.encode, required this.decode}); + + final String Function(T value) encode; + final T Function(String payload) decode; +} + +class PoptartCacheCodecs { + const PoptartCacheCodecs._(); + + static final profileViewDetailed = JsonStringCacheCodec( + encode: (profile) => jsonEncode(profile.toJson()), + decode: (payload) => ProfileViewDetailed.fromJson(_decodeObject(payload)), + ); + + static final postView = JsonStringCacheCodec( + encode: (post) => jsonEncode(post.toJson()), + decode: (payload) => PostView.fromJson(_decodeObject(payload)), + ); + + static final feedViewPost = JsonStringCacheCodec( + encode: (post) => jsonEncode(post.toJson()), + decode: (payload) => FeedViewPost.fromJson(_decodeObject(payload)), + ); + + static final threadViewPost = JsonStringCacheCodec( + encode: (thread) => jsonEncode(thread.toJson()), + decode: (payload) => ThreadViewPost.fromJson(_decodeObject(payload)), + ); + + static final labelerPolicies = JsonStringCacheCodec( + encode: (policies) => jsonEncode(policies.toJson()), + decode: (payload) => LabelerPolicies.fromJson(_decodeObject(payload)), + ); + + static String encodeModerationPreferences(List preferences) { + return jsonEncode(preferences.map((preference) => preference.toJson()).toList()); + } + + static List decodeModerationPreferences(String payload) { + final decoded = jsonDecode(payload); + if (decoded is! List) { + throw FormatException('Expected cached moderation preferences to be a JSON list.', payload); + } + return decoded + .map((json) => const UPreferencesConverter().fromJson(Map.from(json as Map))) + .toList(growable: false); + } + + static String encodeFeedPageMetadata({String? cursor, String? lastRequestCursor}) { + return jsonEncode({'cursor': cursor, 'lastRequestCursor': lastRequestCursor}); + } + + static String? decodeFeedPageCursor(String payload) { + return _decodeObject(payload)['cursor'] as String?; + } + + static FeedViewPost decodeSavedOrLikedPost(String payload) { + final decoded = _decodeObject(payload); + if (decoded['post'] is Map) { + return FeedViewPost.fromJson(decoded); + } + return FeedViewPost(post: PostView.fromJson(decoded)); + } + + static PostView decodeSavedOrLikedPostView(String payload) => decodeSavedOrLikedPost(payload).post; + + static Map _decodeObject(String payload) { + final decoded = jsonDecode(payload); + if (decoded is! Map) { + throw FormatException('Expected cached payload to be a JSON object.', payload); + } + return Map.from(decoded); + } +} diff --git a/lib/core/network/clients/poptart_clients.dart b/lib/core/network/clients/poptart_clients.dart new file mode 100644 index 0000000..c2fe4ab --- /dev/null +++ b/lib/core/network/clients/poptart_clients.dart @@ -0,0 +1,295 @@ +part of '../poptart_client_adapter.dart'; + +class Bluesky { + Bluesky._(this._client); + + factory Bluesky.fromSession( + Session session, { + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return Bluesky._( + PoptartClient.fromSession( + session, + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + factory Bluesky.fromOAuthSession( + OAuthSession session, { + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return Bluesky._( + PoptartClient.fromOAuthSession( + session, + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + factory Bluesky.anonymous({ + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return Bluesky._( + PoptartClient.anonymous( + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + final PoptartClient _client; + + Session? get session => _client.session; + OAuthSession? get oAuthSession => _client.oAuthSession; + String get service => _client.service; + Map get headers => _client.headers; + + BlueskyAtProto get atproto => BlueskyAtProto._(_client); + BlueskyActorService get actor => BlueskyActorService._(_client); + BlueskyBookmarkService get bookmark => BlueskyBookmarkService._(_client); + BlueskyFeedService get feed => BlueskyFeedService._(_client); + BlueskyGraphService get graph => BlueskyGraphService._(_client); + BlueskyLabelerService get labeler => BlueskyLabelerService._(_client); + BlueskyNotificationService get notification => BlueskyNotificationService._(_client); + BlueskyUnspeccedService get unspecced => BlueskyUnspeccedService._(_client); + BlueskyVideoService get video => BlueskyVideoService._(_client); + + Future> call( + XRPCMethod method, { + String? service, + Map? headers, + P? parameters, + I? input, + }) { + final descriptor = method.methodDescriptor; + return _client.call( + method, + service: service, + headers: headers, + parameters: _coerceDescriptorParameters(descriptor, parameters) as P?, + input: _coerceDescriptorInput(descriptor, input) as I?, + ); + } + + Future> get( + NSID methodId, { + String? service, + Map? headers, + Map? parameters, + ResponseDataBuilder? to, + ResponseDataAdaptor? adaptor, + }) { + return _client.get(methodId, service: service, headers: headers, parameters: parameters, to: to, adaptor: adaptor); + } + + Future> post( + NSID methodId, { + String? service, + Map? headers, + Map? parameters, + Object? body, + ResponseDataBuilder? to, + }) { + return _client.post(methodId, service: service, headers: headers, parameters: parameters, body: body, to: to); + } +} + +class BlueskyChat extends Bluesky { + BlueskyChat._(super.client) : super._(); + + factory BlueskyChat.fromSession( + Session session, { + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return BlueskyChat._( + PoptartClient.fromSession( + session, + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + factory BlueskyChat.fromOAuthSession( + OAuthSession session, { + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return BlueskyChat._( + PoptartClient.fromOAuthSession( + session, + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + BlueskyConvoService get convo => BlueskyConvoService._(_client); +} + +typedef ATProto = BlueskyAtProto; + +class BlueskyAtProto { + BlueskyAtProto._(this._client); + + factory BlueskyAtProto.anonymous({ + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return BlueskyAtProto._( + PoptartClient.anonymous( + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + factory BlueskyAtProto.fromOAuthSession( + OAuthSession session, { + Map? headers, + Protocol? protocol, + String? service, + String? relayService, + Duration? timeout, + RetryConfig? retryConfig, + GetClient? getClient, + PostClient? postClient, + }) { + return BlueskyAtProto._( + PoptartClient.fromOAuthSession( + session, + headers: headers, + protocol: protocol, + service: service, + relayService: relayService, + timeout: timeout, + retryConfig: retryConfig, + getClient: getClient, + postClient: postClient, + ), + ); + } + + final PoptartClient _client; + + Session? get session => _client.session; + OAuthSession? get oAuthSession => _client.oAuthSession; + String get service => _client.service; + + AtProtoIdentityService get identity => AtProtoIdentityService._(_client); + AtProtoModerationService get moderation => AtProtoModerationService._(_client); + AtProtoRepoService get repo => AtProtoRepoService._(_client); + AtProtoServerService get server => AtProtoServerService._(_client); + + Future> get( + NSID methodId, { + String? service, + Map? headers, + Map? parameters, + ResponseDataBuilder? to, + ResponseDataAdaptor? adaptor, + }) { + return _client.get(methodId, service: service, headers: headers, parameters: parameters, to: to, adaptor: adaptor); + } +} + +Future> createSession({ + required String identifier, + required String password, + String? service, +}) async { + final response = await PoptartClient.anonymous().call( + comAtprotoServerCreateSession, + service: service, + input: ServerCreateSessionInput(identifier: identifier, password: password), + ); + + return _sessionResponse(response, _sessionFromCreateSessionOutput(response.data)); +} + +Future> refreshSession({required String refreshJwt, String? service}) async { + final response = await PoptartClient.anonymous( + headers: {'Authorization': 'Bearer $refreshJwt'}, + ).call(comAtprotoServerRefreshSession, service: service); + + return _sessionResponse(response, _sessionFromRefreshSessionOutput(response.data)); +} diff --git a/lib/core/network/poptart_client_adapter.dart b/lib/core/network/poptart_client_adapter.dart index f1b059f..e214f8e 100644 --- a/lib/core/network/poptart_client_adapter.dart +++ b/lib/core/network/poptart_client_adapter.dart @@ -1,665 +1,115 @@ +import 'dart:developer' as developer; import 'dart:typed_data'; +import 'package:lazurite/shared/utils/atproto_datetime.dart'; import 'package:poptart_core/poptart_core.dart'; -import 'package:poptart_lex/app/bsky/actor.dart' as actor_methods; -import 'package:poptart_lex/app/bsky/bookmark.dart' as bookmark_methods; -import 'package:poptart_lex/app/bsky/feed.dart' as feed_methods; -import 'package:poptart_lex/app/bsky/graph.dart' as graph_methods; -import 'package:poptart_lex/app/bsky/labeler.dart' as labeler_methods; -import 'package:poptart_lex/app/bsky/notification.dart' as notification_methods; -import 'package:poptart_lex/app/bsky/unspecced.dart' as unspecced_methods; -import 'package:poptart_lex/app/bsky/video.dart' as video_methods; -import 'package:poptart_lex/chat/bsky/convo.dart' as convo_methods; -import 'package:poptart_lex/com/atproto/identity.dart' as identity_methods; -import 'package:poptart_lex/com/atproto/moderation.dart' as moderation_methods; -import 'package:poptart_lex/com/atproto/repo.dart' as repo_methods; -import 'package:poptart_lex/com/atproto/server.dart' as server_methods; -import 'package:poptart_lex/com/atproto/server/create_session/input.dart'; -import 'package:poptart_lex/com/atproto/server/create_session/output.dart'; -import 'package:poptart_lex/com/atproto/server/refresh_session/output.dart'; +import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/get_preferences.dart'; +import 'package:poptart_lex/app/bsky/actor/get_profile.dart'; +import 'package:poptart_lex/app/bsky/actor/get_profiles.dart'; +import 'package:poptart_lex/app/bsky/actor/put_preferences.dart'; +import 'package:poptart_lex/app/bsky/actor/search_actors.dart'; +import 'package:poptart_lex/app/bsky/actor/search_actors_typeahead.dart'; +import 'package:poptart_lex/app/bsky/bookmark/create_bookmark.dart'; +import 'package:poptart_lex/app/bsky/bookmark/delete_bookmark.dart'; +import 'package:poptart_lex/app/bsky/bookmark/get_bookmarks.dart'; +import 'package:poptart_lex/app/bsky/feed/get_actor_likes.dart'; +import 'package:poptart_lex/app/bsky/feed/get_author_feed.dart'; +import 'package:poptart_lex/app/bsky/feed/get_feed.dart'; +import 'package:poptart_lex/app/bsky/feed/get_feed_generator.dart'; +import 'package:poptart_lex/app/bsky/feed/get_feed_generators.dart'; +import 'package:poptart_lex/app/bsky/feed/get_likes.dart'; +import 'package:poptart_lex/app/bsky/feed/get_list_feed.dart'; +import 'package:poptart_lex/app/bsky/feed/get_post_thread.dart'; +import 'package:poptart_lex/app/bsky/feed/get_posts.dart'; +import 'package:poptart_lex/app/bsky/feed/get_quotes.dart'; +import 'package:poptart_lex/app/bsky/feed/get_reposted_by.dart'; +import 'package:poptart_lex/app/bsky/feed/get_suggested_feeds.dart'; +import 'package:poptart_lex/app/bsky/feed/get_timeline.dart'; +import 'package:poptart_lex/app/bsky/feed/like.dart'; +import 'package:poptart_lex/app/bsky/feed/post.dart'; +import 'package:poptart_lex/app/bsky/feed/repost.dart'; +import 'package:poptart_lex/app/bsky/feed/search_posts.dart'; +import 'package:poptart_lex/app/bsky/graph/block.dart'; +import 'package:poptart_lex/app/bsky/graph/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/follow.dart'; +import 'package:poptart_lex/app/bsky/graph/get_actor_starter_packs.dart'; +import 'package:poptart_lex/app/bsky/graph/get_followers.dart'; +import 'package:poptart_lex/app/bsky/graph/get_follows.dart'; +import 'package:poptart_lex/app/bsky/graph/get_list.dart'; +import 'package:poptart_lex/app/bsky/graph/get_lists.dart'; +import 'package:poptart_lex/app/bsky/graph/get_lists_with_membership.dart'; +import 'package:poptart_lex/app/bsky/graph/get_starter_pack.dart'; +import 'package:poptart_lex/app/bsky/graph/get_suggested_follows_by_actor.dart'; +import 'package:poptart_lex/app/bsky/graph/list.dart'; +import 'package:poptart_lex/app/bsky/graph/listblock.dart'; +import 'package:poptart_lex/app/bsky/graph/listitem.dart'; +import 'package:poptart_lex/app/bsky/graph/mute_actor.dart'; +import 'package:poptart_lex/app/bsky/graph/mute_actor_list.dart'; +import 'package:poptart_lex/app/bsky/graph/search_starter_packs.dart'; +import 'package:poptart_lex/app/bsky/graph/starterpack.dart'; +import 'package:poptart_lex/app/bsky/graph/unmute_actor.dart'; +import 'package:poptart_lex/app/bsky/graph/unmute_actor_list.dart'; +import 'package:poptart_lex/app/bsky/labeler/get_services.dart'; +import 'package:poptart_lex/app/bsky/notification/get_unread_count.dart'; +import 'package:poptart_lex/app/bsky/notification/list_notifications.dart'; +import 'package:poptart_lex/app/bsky/notification/register_push.dart'; +import 'package:poptart_lex/app/bsky/notification/unregister_push.dart'; +import 'package:poptart_lex/app/bsky/notification/update_seen.dart'; +import 'package:poptart_lex/app/bsky/richtext/facet.dart'; +import 'package:poptart_lex/app/bsky/unspecced/get_popular_feed_generators.dart'; +import 'package:poptart_lex/app/bsky/unspecced/get_trending_topics.dart'; +import 'package:poptart_lex/app/bsky/unspecced/get_trends.dart'; +import 'package:poptart_lex/app/bsky/video/defs.dart'; +import 'package:poptart_lex/app/bsky/video/get_job_status.dart'; +import 'package:poptart_lex/app/bsky/video/get_upload_limits.dart'; +import 'package:poptart_lex/app/bsky/video/upload_video.dart'; +import 'package:poptart_lex/chat/bsky/convo/defs.dart'; +import 'package:poptart_lex/chat/bsky/convo/delete_message_for_self.dart'; +import 'package:poptart_lex/chat/bsky/convo/get_convo_for_members.dart'; +import 'package:poptart_lex/chat/bsky/convo/get_messages.dart'; +import 'package:poptart_lex/chat/bsky/convo/list_convos.dart'; +import 'package:poptart_lex/chat/bsky/convo/mute_convo.dart'; +import 'package:poptart_lex/chat/bsky/convo/send_message.dart'; +import 'package:poptart_lex/chat/bsky/convo/unmute_convo.dart'; +import 'package:poptart_lex/chat/bsky/convo/update_read.dart'; +import 'package:poptart_lex/com/atproto/identity/resolve_handle.dart'; +import 'package:poptart_lex/com/atproto/moderation/create_report.dart'; +import 'package:poptart_lex/com/atproto/moderation/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; +import 'package:poptart_lex/com/atproto/repo/create_record.dart'; +import 'package:poptart_lex/com/atproto/repo/delete_record.dart'; +import 'package:poptart_lex/com/atproto/repo/describe_repo.dart'; +import 'package:poptart_lex/com/atproto/repo/get_record.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; +import 'package:poptart_lex/com/atproto/repo/put_record.dart'; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; +import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; +import 'package:poptart_lex/com/atproto/server/create_session.dart'; +import 'package:poptart_lex/com/atproto/server/get_service_auth.dart'; +import 'package:poptart_lex/com/atproto/server/get_session.dart'; +import 'package:poptart_lex/com/atproto/server/refresh_session.dart'; import 'package:poptart_oauth/poptart_oauth.dart' show OAuthSession; export 'package:poptart_core/poptart_core.dart'; export 'package:poptart_oauth/poptart_oauth.dart'; export 'package:poptart_xrpc/poptart_xrpc.dart'; -class Bluesky { - Bluesky._(this._client); - - factory Bluesky.fromSession( - Session session, { - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return Bluesky._( - PoptartClient.fromSession( - session, - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - factory Bluesky.fromOAuthSession( - OAuthSession session, { - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return Bluesky._( - PoptartClient.fromOAuthSession( - session, - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - factory Bluesky.anonymous({ - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return Bluesky._( - PoptartClient.anonymous( - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - final PoptartClient _client; - - Session? get session => _client.session; - OAuthSession? get oAuthSession => _client.oAuthSession; - String get service => _client.service; - Map get headers => _client.headers; - - BlueskyAtProto get atproto => BlueskyAtProto._(_client); - dynamic get actor => _XrpcNamespace(_client, 'app.bsky.actor'); - dynamic get bookmark => _XrpcNamespace(_client, 'app.bsky.bookmark'); - dynamic get feed => _FeedNamespace(_client); - dynamic get graph => _GraphNamespace(_client); - dynamic get labeler => _XrpcNamespace(_client, 'app.bsky.labeler'); - dynamic get notification => _XrpcNamespace(_client, 'app.bsky.notification'); - dynamic get unspecced => _XrpcNamespace(_client, 'app.bsky.unspecced'); - dynamic get video => _XrpcNamespace(_client, 'app.bsky.video'); - - Future> call( - XRPCMethod method, { - String? service, - Map? headers, - P? parameters, - I? input, - }) { - final descriptor = method.methodDescriptor; - return _client.call( - method, - service: service, - headers: headers, - parameters: _coerceDescriptorParameters(descriptor, parameters) as P?, - input: _coerceDescriptorInput(descriptor, input) as I?, - ); - } - - Future> get( - NSID methodId, { - String? service, - Map? headers, - Map? parameters, - ResponseDataBuilder? to, - ResponseDataAdaptor? adaptor, - }) { - return _client.get(methodId, service: service, headers: headers, parameters: parameters, to: to, adaptor: adaptor); - } - - Future> post( - NSID methodId, { - String? service, - Map? headers, - Map? parameters, - dynamic body, - ResponseDataBuilder? to, - }) { - return _client.post(methodId, service: service, headers: headers, parameters: parameters, body: body, to: to); - } -} - -class BlueskyChat extends Bluesky { - BlueskyChat._(super.client) : super._(); - - factory BlueskyChat.fromSession( - Session session, { - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return BlueskyChat._( - PoptartClient.fromSession( - session, - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - factory BlueskyChat.fromOAuthSession( - OAuthSession session, { - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return BlueskyChat._( - PoptartClient.fromOAuthSession( - session, - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - dynamic get convo => _XrpcNamespace(_client, 'chat.bsky.convo'); -} - -typedef ATProto = BlueskyAtProto; - -class BlueskyAtProto { - BlueskyAtProto._(this._client); - - factory BlueskyAtProto.anonymous({ - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return BlueskyAtProto._( - PoptartClient.anonymous( - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - factory BlueskyAtProto.fromOAuthSession( - OAuthSession session, { - Map? headers, - Protocol? protocol, - String? service, - String? relayService, - Duration? timeout, - RetryConfig? retryConfig, - GetClient? getClient, - PostClient? postClient, - }) { - return BlueskyAtProto._( - PoptartClient.fromOAuthSession( - session, - headers: headers, - protocol: protocol, - service: service, - relayService: relayService, - timeout: timeout, - retryConfig: retryConfig, - getClient: getClient, - postClient: postClient, - ), - ); - } - - final PoptartClient _client; - - Session? get session => _client.session; - OAuthSession? get oAuthSession => _client.oAuthSession; - String get service => _client.service; - - dynamic get identity => _XrpcNamespace(_client, 'com.atproto.identity'); - dynamic get moderation => _XrpcNamespace(_client, 'com.atproto.moderation'); - dynamic get repo => _RepoNamespace(_client); - dynamic get server => _XrpcNamespace(_client, 'com.atproto.server'); - - Future> get( - NSID methodId, { - String? service, - Map? headers, - Map? parameters, - ResponseDataBuilder? to, - ResponseDataAdaptor? adaptor, - }) { - return _client.get(methodId, service: service, headers: headers, parameters: parameters, to: to, adaptor: adaptor); - } -} - -Future> createSession({ - required String identifier, - required String password, - String? service, -}) async { - final response = await PoptartClient.anonymous().call( - server_methods.comAtprotoServerCreateSession, - service: service, - input: ServerCreateSessionInput(identifier: identifier, password: password), - ); - - return _sessionResponse(response, _sessionFromCreateSessionOutput(response.data)); -} - -Future> refreshSession({required String refreshJwt, String? service}) async { - final response = await PoptartClient.anonymous( - headers: {'Authorization': 'Bearer $refreshJwt'}, - ).call(server_methods.comAtprotoServerRefreshSession, service: service); - - return _sessionResponse(response, _sessionFromRefreshSessionOutput(response.data)); -} - -XRPCResponse _sessionResponse(final XRPCResponse response, final Session session) { - return XRPCResponse( - headers: response.headers, - status: response.status, - request: response.request, - rateLimit: response.rateLimit, - data: session, - ); -} - -Session _sessionFromCreateSessionOutput(final ServerCreateSessionOutput output) { - return Session.fromJson(output.toJson()); -} - -Session _sessionFromRefreshSessionOutput(final ServerRefreshSessionOutput output) { - return Session.fromJson(output.toJson()); -} - -class _FeedNamespace extends _XrpcNamespace { - _FeedNamespace(PoptartClient client) : super(client, 'app.bsky.feed'); - - dynamic get like => _RecordNamespace(_client, 'app.bsky.feed.like'); - dynamic get post => _RecordNamespace(_client, 'app.bsky.feed.post'); - dynamic get repost => _RecordNamespace(_client, 'app.bsky.feed.repost'); -} - -class _GraphNamespace extends _XrpcNamespace { - _GraphNamespace(PoptartClient client) : super(client, 'app.bsky.graph'); - - dynamic get block => _RecordNamespace(_client, 'app.bsky.graph.block'); - dynamic get follow => _RecordNamespace(_client, 'app.bsky.graph.follow'); - dynamic get listblock => _RecordNamespace(_client, 'app.bsky.graph.listblock'); - dynamic get listitem => _RecordNamespace(_client, 'app.bsky.graph.listitem'); - dynamic get starterpack => _RecordNamespace(_client, 'app.bsky.graph.starterpack'); -} - -class _RepoNamespace extends _XrpcNamespace { - _RepoNamespace(PoptartClient client) : super(client, 'com.atproto.repo'); - - Future> uploadBlob({ - required Uint8List bytes, - Map? $headers, - String? $service, - }) { - return _invokeDescriptor( - _client, - repo_methods.comAtprotoRepoUploadBlob, - headers: $headers, - service: $service, - input: bytes, - ); - } -} - -class _RecordNamespace { - _RecordNamespace(this._client, this._collection); - - final PoptartClient _client; - final String _collection; - - Future> create({ - String? rkey, - Map? $headers, - String? $service, - bool? validate, - String? swapCommit, - Map? record, - dynamic subject, - dynamic cid, - DateTime? createdAt, - dynamic list, - dynamic item, - String? name, - String? description, - dynamic avatar, - dynamic labels, - dynamic purpose, - }) { - final body = record ?? {}; - body.putIfAbsent(r'$type', () => _collection); - _putIfPresent(body, 'subject', subject); - _putIfPresent(body, 'cid', cid); - _putIfPresent(body, 'createdAt', createdAt); - _putIfPresent(body, 'list', list); - _putIfPresent(body, 'item', item); - _putIfPresent(body, 'name', name); - _putIfPresent(body, 'description', description); - _putIfPresent(body, 'avatar', avatar); - _putIfPresent(body, 'labels', labels); - _putIfPresent(body, 'purpose', purpose); - return _invokeDescriptor( - _client, - repo_methods.comAtprotoRepoCreateRecord, - headers: $headers, - service: $service, - input: { - 'repo': _repoDid(_client), - 'collection': _collection, - 'rkey': ?rkey, - 'validate': ?validate, - 'swapCommit': ?swapCommit, - 'record': _normalizeJson(body), - }, - ); - } - - Future> put({ - required String rkey, - Map? $headers, - String? $service, - bool? validate, - String? swapRecord, - String? swapCommit, - Map? record, - String? name, - String? description, - dynamic avatar, - dynamic labels, - dynamic purpose, - dynamic list, - }) { - final body = record ?? {}; - body.putIfAbsent(r'$type', () => _collection); - _putIfPresent(body, 'name', name); - _putIfPresent(body, 'description', description); - _putIfPresent(body, 'avatar', avatar); - _putIfPresent(body, 'labels', labels); - _putIfPresent(body, 'purpose', purpose); - _putIfPresent(body, 'list', list); - return _invokeDescriptor( - _client, - repo_methods.comAtprotoRepoPutRecord, - headers: $headers, - service: $service, - input: { - 'repo': _repoDid(_client), - 'collection': _collection, - 'rkey': rkey, - 'validate': ?validate, - 'swapRecord': ?swapRecord, - 'swapCommit': ?swapCommit, - 'record': _normalizeJson(body), - }, - ); - } - - Future> delete({ - required String rkey, - Map? $headers, - String? $service, - String? swapRecord, - String? swapCommit, - }) { - return _invokeDescriptor( - _client, - repo_methods.comAtprotoRepoDeleteRecord, - headers: $headers, - service: $service, - input: { - 'repo': _repoDid(_client), - 'collection': _collection, - 'rkey': rkey, - 'swapRecord': ?swapRecord, - 'swapCommit': ?swapCommit, - }, - ); - } -} - -class _XrpcNamespace { - _XrpcNamespace(this._client, this._prefix); - - final PoptartClient _client; - final String _prefix; - - @override - dynamic noSuchMethod(Invocation invocation) { - if (!invocation.isMethod) return super.noSuchMethod(invocation); - final method = _symbolName(invocation.memberName); - final named = {}; - invocation.namedArguments.forEach((key, value) => named[_symbolName(key)] = value); - final headers = named.remove(r'$headers') as Map?; - final service = named.remove(r'$service') as String?; - final descriptor = _descriptorFor('$_prefix.$method'); - if (descriptor == null) return super.noSuchMethod(invocation); - return _invokeDescriptor(_client, descriptor, headers: headers, service: service, values: named); - } -} - -Future> _invokeDescriptor( - PoptartClient client, - XRPCMethodDescriptor descriptor, { - Map? headers, - String? service, - Map? values, - dynamic input, -}) { - final normalized = _normalizeJson(values ?? const {}) as Map; - final dynamic parameters = descriptor.isQuery ? _coerceDescriptorParameters(descriptor, normalized) : null; - final dynamic body = - _coerceDescriptorInput(descriptor, input) ?? - (descriptor.isProcedure - ? descriptor.inputFromJson?.call(normalized) ?? (normalized.isEmpty ? null : normalized) - : null); - return client.call(descriptor, service: service, headers: headers, parameters: parameters, input: body); -} - -dynamic _coerceDescriptorParameters(XRPCMethodDescriptor descriptor, dynamic parameters) { - if (parameters == null) { - return null; - } - if (parameters is! Map) { - return parameters; - } - - final normalized = _normalizeJson(parameters) as Map; - final converter = descriptor.parametersFromJson; - if (converter != null) { - return converter.call(normalized); - } - return normalized.isEmpty ? null : normalized; -} - -dynamic _coerceDescriptorInput(XRPCMethodDescriptor descriptor, dynamic input) { - if (input == null) { - return null; - } - if (input is! Map) { - return input; - } - - final normalized = _normalizeJson(input) as Map; - final converter = descriptor.inputFromJson; - if (converter != null) { - return converter.call(normalized); - } - return normalized.isEmpty ? null : normalized; -} - -XRPCMethodDescriptor? _descriptorFor(String nsid) { - return switch (nsid) { - 'app.bsky.actor.getPreferences' => actor_methods.appBskyActorGetPreferences, - 'app.bsky.actor.getProfile' => actor_methods.appBskyActorGetProfile, - 'app.bsky.actor.getProfiles' => actor_methods.appBskyActorGetProfiles, - 'app.bsky.actor.putPreferences' => actor_methods.appBskyActorPutPreferences, - 'app.bsky.actor.searchActors' => actor_methods.appBskyActorSearchActors, - 'app.bsky.actor.searchActorsTypeahead' => actor_methods.appBskyActorSearchActorsTypeahead, - 'app.bsky.bookmark.createBookmark' => bookmark_methods.appBskyBookmarkCreateBookmark, - 'app.bsky.bookmark.deleteBookmark' => bookmark_methods.appBskyBookmarkDeleteBookmark, - 'app.bsky.bookmark.getBookmarks' => bookmark_methods.appBskyBookmarkGetBookmarks, - 'app.bsky.feed.getActorLikes' => feed_methods.appBskyFeedGetActorLikes, - 'app.bsky.feed.getAuthorFeed' => feed_methods.appBskyFeedGetAuthorFeed, - 'app.bsky.feed.getFeed' => feed_methods.appBskyFeedGetFeed, - 'app.bsky.feed.getFeedGenerator' => feed_methods.appBskyFeedGetFeedGenerator, - 'app.bsky.feed.getFeedGenerators' => feed_methods.appBskyFeedGetFeedGenerators, - 'app.bsky.feed.getLikes' => feed_methods.appBskyFeedGetLikes, - 'app.bsky.feed.getListFeed' => feed_methods.appBskyFeedGetListFeed, - 'app.bsky.feed.getPostThread' => feed_methods.appBskyFeedGetPostThread, - 'app.bsky.feed.getPosts' => feed_methods.appBskyFeedGetPosts, - 'app.bsky.feed.getQuotes' => feed_methods.appBskyFeedGetQuotes, - 'app.bsky.feed.getRepostedBy' => feed_methods.appBskyFeedGetRepostedBy, - 'app.bsky.feed.getSuggestedFeeds' => feed_methods.appBskyFeedGetSuggestedFeeds, - 'app.bsky.feed.getTimeline' => feed_methods.appBskyFeedGetTimeline, - 'app.bsky.feed.searchPosts' => feed_methods.appBskyFeedSearchPosts, - 'app.bsky.graph.getActorStarterPacks' => graph_methods.appBskyGraphGetActorStarterPacks, - 'app.bsky.graph.getFollowers' => graph_methods.appBskyGraphGetFollowers, - 'app.bsky.graph.getFollows' => graph_methods.appBskyGraphGetFollows, - 'app.bsky.graph.getList' => graph_methods.appBskyGraphGetList, - 'app.bsky.graph.getListFeed' => null, - 'app.bsky.graph.getLists' => graph_methods.appBskyGraphGetLists, - 'app.bsky.graph.getListsWithMembership' => graph_methods.appBskyGraphGetListsWithMembership, - 'app.bsky.graph.getStarterPack' => graph_methods.appBskyGraphGetStarterPack, - 'app.bsky.graph.getSuggestedFollowsByActor' => graph_methods.appBskyGraphGetSuggestedFollowsByActor, - 'app.bsky.graph.muteActor' => graph_methods.appBskyGraphMuteActor, - 'app.bsky.graph.muteActorList' => graph_methods.appBskyGraphMuteActorList, - 'app.bsky.graph.searchStarterPacks' => graph_methods.appBskyGraphSearchStarterPacks, - 'app.bsky.graph.unmuteActor' => graph_methods.appBskyGraphUnmuteActor, - 'app.bsky.graph.unmuteActorList' => graph_methods.appBskyGraphUnmuteActorList, - 'app.bsky.labeler.getServices' => labeler_methods.appBskyLabelerGetServices, - 'app.bsky.notification.getUnreadCount' => notification_methods.appBskyNotificationGetUnreadCount, - 'app.bsky.notification.listNotifications' => notification_methods.appBskyNotificationListNotifications, - 'app.bsky.notification.registerPush' => notification_methods.appBskyNotificationRegisterPush, - 'app.bsky.notification.unregisterPush' => notification_methods.appBskyNotificationUnregisterPush, - 'app.bsky.notification.updateSeen' => notification_methods.appBskyNotificationUpdateSeen, - 'app.bsky.unspecced.getPopularFeedGenerators' => unspecced_methods.appBskyUnspeccedGetPopularFeedGenerators, - 'app.bsky.unspecced.getTopicFeed' => unspecced_methods.appBskyUnspeccedGetTaggedSuggestions, - 'app.bsky.unspecced.getTrendingTopics' => unspecced_methods.appBskyUnspeccedGetTrendingTopics, - 'app.bsky.unspecced.getTrends' => unspecced_methods.appBskyUnspeccedGetTrends, - 'app.bsky.video.getJobStatus' => video_methods.appBskyVideoGetJobStatus, - 'app.bsky.video.getUploadLimits' => video_methods.appBskyVideoGetUploadLimits, - 'app.bsky.video.uploadVideo' => video_methods.appBskyVideoUploadVideo, - 'chat.bsky.convo.deleteMessageForSelf' => convo_methods.chatBskyConvoDeleteMessageForSelf, - 'chat.bsky.convo.getConvoForMembers' => convo_methods.chatBskyConvoGetConvoForMembers, - 'chat.bsky.convo.getMessages' => convo_methods.chatBskyConvoGetMessages, - 'chat.bsky.convo.listConvos' => convo_methods.chatBskyConvoListConvos, - 'chat.bsky.convo.muteConvo' => convo_methods.chatBskyConvoMuteConvo, - 'chat.bsky.convo.sendMessage' => convo_methods.chatBskyConvoSendMessage, - 'chat.bsky.convo.unmuteConvo' => convo_methods.chatBskyConvoUnmuteConvo, - 'chat.bsky.convo.updateRead' => convo_methods.chatBskyConvoUpdateRead, - 'com.atproto.identity.resolveHandle' => identity_methods.comAtprotoIdentityResolveHandle, - 'com.atproto.moderation.createReport' => moderation_methods.comAtprotoModerationCreateReport, - 'com.atproto.repo.applyWrites' => repo_methods.comAtprotoRepoApplyWrites, - 'com.atproto.repo.createRecord' => repo_methods.comAtprotoRepoCreateRecord, - 'com.atproto.repo.deleteRecord' => repo_methods.comAtprotoRepoDeleteRecord, - 'com.atproto.repo.describeRepo' => repo_methods.comAtprotoRepoDescribeRepo, - 'com.atproto.repo.getRecord' => repo_methods.comAtprotoRepoGetRecord, - 'com.atproto.repo.listRecords' => repo_methods.comAtprotoRepoListRecords, - 'com.atproto.repo.putRecord' => repo_methods.comAtprotoRepoPutRecord, - 'com.atproto.repo.uploadBlob' => repo_methods.comAtprotoRepoUploadBlob, - 'com.atproto.server.getSession' => server_methods.comAtprotoServerGetSession, - _ => null, - } - as XRPCMethodDescriptor?; -} - -dynamic _normalizeJson(dynamic value) { - if (value == null || value is String || value is num || value is bool) return value; - if (value is DateTime) return value.toUtc().toIso8601String(); - if (value is AtUri || value is NSID) return value.toString(); - if (value is Blob || value is BlobRef) return value.toJson(); - if (value is List) return value.map(_normalizeJson).toList(growable: false); - if (value is Map) { - return value.map((key, val) => MapEntry(key.toString(), _normalizeJson(val))); - } - final dynamic dynamicValue = value; - try { - return dynamicValue.toJson(); - } catch (_) { - return value.toString(); - } -} - -void _putIfPresent(Map target, String key, dynamic value) { - if (value != null) target[key] = _normalizeJson(value); -} - -String _repoDid(PoptartClient client) { - return client.session?.did ?? - client.oAuthSession?.sub ?? - (throw StateError('Authenticated repo DID is unavailable.')); -} - -String _symbolName(Symbol symbol) { - final text = symbol.toString(); - return text.substring(8, text.length - 2); -} +part 'clients/poptart_clients.dart'; +part 'services/atproto_identity_service.dart'; +part 'services/atproto_moderation_service.dart'; +part 'services/atproto_repo_service.dart'; +part 'services/atproto_server_service.dart'; +part 'services/bluesky_actor_service.dart'; +part 'services/bluesky_bookmark_service.dart'; +part 'services/bluesky_chat_services.dart'; +part 'services/bluesky_feed_service.dart'; +part 'services/bluesky_graph_service.dart'; +part 'services/bluesky_labeler_service.dart'; +part 'services/bluesky_notification_service.dart'; +part 'services/bluesky_unspecced_service.dart'; +part 'services/bluesky_video_service.dart'; +part 'services/current_repo_record_service.dart'; +part 'services/feed_record_services.dart'; +part 'services/graph_record_services.dart'; +part 'services/poptart_helpers.dart'; diff --git a/lib/core/network/services/atproto_identity_service.dart b/lib/core/network/services/atproto_identity_service.dart new file mode 100644 index 0000000..576a101 --- /dev/null +++ b/lib/core/network/services/atproto_identity_service.dart @@ -0,0 +1,20 @@ +part of '../poptart_client_adapter.dart'; + +class AtProtoIdentityService { + AtProtoIdentityService._(this._client); + + final PoptartClient _client; + + Future> resolveHandle({ + required String handle, + Map? $headers, + String? $service, + }) { + return _client.call( + comAtprotoIdentityResolveHandle, + headers: $headers, + service: $service, + parameters: IdentityResolveHandleInput(handle: handle), + ); + } +} diff --git a/lib/core/network/services/atproto_moderation_service.dart b/lib/core/network/services/atproto_moderation_service.dart new file mode 100644 index 0000000..4b9a90d --- /dev/null +++ b/lib/core/network/services/atproto_moderation_service.dart @@ -0,0 +1,23 @@ +part of '../poptart_client_adapter.dart'; + +class AtProtoModerationService { + AtProtoModerationService._(this._client); + + final PoptartClient _client; + + Future> createReport({ + required ReasonType reasonType, + String? reason, + required UModerationCreateReportSubject subject, + ModTool? modTool, + Map? $headers, + String? $service, + }) { + return _client.call( + comAtprotoModerationCreateReport, + headers: $headers, + service: $service, + input: ModerationCreateReportInput(reasonType: reasonType, reason: reason, subject: subject, modTool: modTool), + ); + } +} diff --git a/lib/core/network/services/atproto_repo_service.dart b/lib/core/network/services/atproto_repo_service.dart new file mode 100644 index 0000000..fb3cd38 --- /dev/null +++ b/lib/core/network/services/atproto_repo_service.dart @@ -0,0 +1,149 @@ +part of '../poptart_client_adapter.dart'; + +class AtProtoRepoService { + AtProtoRepoService._(this._client); + + final PoptartClient _client; + + Future> describeRepo({ + required String repo, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoDescribeRepo, + headers: $headers, + service: $service, + parameters: RepoDescribeRepoInput(repo: repo), + ); + + Future> getRecord({ + required String repo, + required String collection, + required String rkey, + String? cid, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoGetRecord, + headers: $headers, + service: $service, + parameters: RepoGetRecordInput(repo: repo, collection: collection, rkey: rkey, cid: cid), + ); + + Future> listRecords({ + required String repo, + required String collection, + int limit = 50, + String? cursor, + bool? reverse, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoListRecords, + headers: $headers, + service: $service, + parameters: RepoListRecordsInput( + repo: repo, + collection: collection, + limit: limit, + cursor: cursor, + reverse: reverse, + ), + ); + + Future> createRecord({ + required String repo, + required String collection, + String? rkey, + bool? validate, + required Map record, + String? swapCommit, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoCreateRecord, + headers: $headers, + service: $service, + input: RepoCreateRecordInput( + repo: repo, + collection: collection, + rkey: rkey, + validate: validate, + record: _normalizeJson(record) as Map, + swapCommit: swapCommit, + ), + ); + + Future> putRecord({ + required String repo, + required String collection, + required String rkey, + bool? validate, + required Map record, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoPutRecord, + headers: $headers, + service: $service, + input: RepoPutRecordInput( + repo: repo, + collection: collection, + rkey: rkey, + validate: validate, + record: _normalizeJson(record) as Map, + swapRecord: swapRecord, + swapCommit: swapCommit, + ), + ); + + Future> deleteRecord({ + required String repo, + required String collection, + required String rkey, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoDeleteRecord, + headers: $headers, + service: $service, + input: RepoDeleteRecordInput( + repo: repo, + collection: collection, + rkey: rkey, + swapRecord: swapRecord, + swapCommit: swapCommit, + ), + ); + + Future> uploadBlob({ + required Uint8List bytes, + Map? $headers, + String? $service, + }) { + return _client.call(comAtprotoRepoUploadBlob, headers: $headers, service: $service, input: bytes); + } + + Future> applyWrites({ + required String repo, + bool? validate, + required List writes, + String? swapCommit, + Map? $headers, + String? $service, + }) => _client.call( + comAtprotoRepoApplyWrites, + headers: $headers, + service: $service, + input: + _coerceDescriptorInput( + comAtprotoRepoApplyWrites.methodDescriptor, + RepoApplyWritesInput(repo: repo, validate: validate, writes: writes, swapCommit: swapCommit), + ) + as RepoApplyWritesInput, + ); +} diff --git a/lib/core/network/services/atproto_server_service.dart b/lib/core/network/services/atproto_server_service.dart new file mode 100644 index 0000000..2b9c8ad --- /dev/null +++ b/lib/core/network/services/atproto_server_service.dart @@ -0,0 +1,26 @@ +part of '../poptart_client_adapter.dart'; + +class AtProtoServerService { + AtProtoServerService._(this._client); + + final PoptartClient _client; + + Future> getSession({Map? $headers, String? $service}) { + return _client.call(comAtprotoServerGetSession, headers: $headers, service: $service); + } + + Future> getServiceAuth({ + required String aud, + int? exp, + String? lxm, + Map? $headers, + String? $service, + }) { + return _client.call( + comAtprotoServerGetServiceAuth, + headers: $headers, + service: $service, + parameters: ServerGetServiceAuthInput(aud: aud, exp: exp, lxm: lxm), + ); + } +} diff --git a/lib/core/network/services/bluesky_actor_service.dart b/lib/core/network/services/bluesky_actor_service.dart new file mode 100644 index 0000000..2ccd9cb --- /dev/null +++ b/lib/core/network/services/bluesky_actor_service.dart @@ -0,0 +1,74 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyActorService { + BlueskyActorService._(this._client); + + final PoptartClient _client; + + Future> getPreferences({Map? $headers, String? $service}) { + return _client.call(appBskyActorGetPreferences, headers: $headers, service: $service); + } + + Future> putPreferences({ + required List preferences, + Map? $headers, + String? $service, + }) => _client.call( + appBskyActorPutPreferences, + headers: $headers, + service: $service, + input: + _coerceDescriptorInput( + appBskyActorPutPreferences.methodDescriptor, + ActorPutPreferencesInput(preferences: preferences), + ) + as ActorPutPreferencesInput, + ); + + Future> getProfile({ + required String actor, + Map? $headers, + String? $service, + }) => _client.call( + appBskyActorGetProfile, + headers: $headers, + service: $service, + parameters: ActorGetProfileInput(actor: actor), + ); + + Future> getProfiles({ + required List actors, + Map? $headers, + String? $service, + }) => _client.call( + appBskyActorGetProfiles, + headers: $headers, + service: $service, + parameters: ActorGetProfilesInput(actors: actors), + ); + + Future> searchActors({ + String? q, + int limit = 25, + String? cursor, + Map? $headers, + String? $service, + }) => _client.call( + appBskyActorSearchActors, + headers: $headers, + service: $service, + parameters: ActorSearchActorsInput(q: q, limit: limit, cursor: cursor), + ); + + Future> searchActorsTypeahead({ + String? q, + int limit = 10, + Map? $headers, + String? $service, + }) => _client.call( + appBskyActorSearchActorsTypeahead, + headers: $headers, + service: $service, + parameters: ActorSearchActorsTypeaheadInput(q: q, limit: limit), + ); +} diff --git a/lib/core/network/services/bluesky_bookmark_service.dart b/lib/core/network/services/bluesky_bookmark_service.dart new file mode 100644 index 0000000..8c03e62 --- /dev/null +++ b/lib/core/network/services/bluesky_bookmark_service.dart @@ -0,0 +1,48 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyBookmarkService { + BlueskyBookmarkService._(this._client); + + final PoptartClient _client; + + Future> createBookmark({ + required AtUri uri, + required String cid, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyBookmarkCreateBookmark, + headers: $headers, + service: $service, + input: BookmarkCreateBookmarkInput(uri: uri, cid: cid), + ); + } + + Future> deleteBookmark({ + required AtUri uri, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyBookmarkDeleteBookmark, + headers: $headers, + service: $service, + input: BookmarkDeleteBookmarkInput(uri: uri), + ); + } + + Future> getBookmarks({ + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyBookmarkGetBookmarks, + headers: $headers, + service: $service, + parameters: BookmarkGetBookmarksInput(limit: limit, cursor: cursor), + ); + } +} diff --git a/lib/core/network/services/bluesky_chat_services.dart b/lib/core/network/services/bluesky_chat_services.dart new file mode 100644 index 0000000..d6d81f9 --- /dev/null +++ b/lib/core/network/services/bluesky_chat_services.dart @@ -0,0 +1,54 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyConvoService { + BlueskyConvoService._(this._client); + + final PoptartClient _client; + + Future> listConvos({int limit = 50, String? cursor}) { + return _client.call( + chatBskyConvoListConvos, + parameters: ConvoListConvosInput(limit: limit, cursor: cursor), + ); + } + + Future> getConvoForMembers({required List members}) { + return _client.call(chatBskyConvoGetConvoForMembers, parameters: ConvoGetConvoForMembersInput(members: members)); + } + + Future> getMessages({required String convoId, int limit = 50, String? cursor}) { + return _client.call( + chatBskyConvoGetMessages, + parameters: ConvoGetMessagesInput(convoId: convoId, limit: limit, cursor: cursor), + ); + } + + Future> sendMessage({required String convoId, required MessageInput message}) { + return _client.call( + chatBskyConvoSendMessage, + input: ConvoSendMessageInput(convoId: convoId, message: message), + ); + } + + Future> deleteMessageForSelf({required String convoId, required String messageId}) { + return _client.call( + chatBskyConvoDeleteMessageForSelf, + input: ConvoDeleteMessageForSelfInput(convoId: convoId, messageId: messageId), + ); + } + + Future> muteConvo({required String convoId}) { + return _client.call(chatBskyConvoMuteConvo, input: ConvoMuteConvoInput(convoId: convoId)); + } + + Future> unmuteConvo({required String convoId}) { + return _client.call(chatBskyConvoUnmuteConvo, input: ConvoUnmuteConvoInput(convoId: convoId)); + } + + Future> updateRead({required String convoId, String? messageId}) { + return _client.call( + chatBskyConvoUpdateRead, + input: ConvoUpdateReadInput(convoId: convoId, messageId: messageId), + ); + } +} diff --git a/lib/core/network/services/bluesky_feed_service.dart b/lib/core/network/services/bluesky_feed_service.dart new file mode 100644 index 0000000..b9b8216 --- /dev/null +++ b/lib/core/network/services/bluesky_feed_service.dart @@ -0,0 +1,247 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyFeedService { + BlueskyFeedService._(this._client); + + final PoptartClient _client; + + FeedLikeRecordService get like => FeedLikeRecordService._(_client); + FeedPostRecordService get post => FeedPostRecordService._(_client); + FeedRepostRecordService get repost => FeedRepostRecordService._(_client); + + Future> getTimeline({ + String? algorithm, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetTimeline, + headers: $headers, + service: $service, + parameters: FeedGetTimelineInput(algorithm: algorithm, limit: limit, cursor: cursor), + ); + } + + Future> getFeed({ + required AtUri feed, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetFeed, + headers: $headers, + service: $service, + parameters: FeedGetFeedInput(feed: feed, limit: limit, cursor: cursor), + ); + } + + Future> getAuthorFeed({ + required String actor, + int limit = 50, + String? cursor, + FeedGetAuthorFeedFilter? filter, + bool includePins = false, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetAuthorFeed, + headers: $headers, + service: $service, + parameters: FeedGetAuthorFeedInput( + actor: actor, + limit: limit, + cursor: cursor, + filter: filter, + includePins: includePins, + ), + ); + } + + Future> getSuggestedFeeds({ + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetSuggestedFeeds, + headers: $headers, + service: $service, + parameters: FeedGetSuggestedFeedsInput(limit: limit, cursor: cursor), + ); + } + + Future> getFeedGenerator({ + required AtUri feed, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetFeedGenerator, + headers: $headers, + service: $service, + parameters: FeedGetFeedGeneratorInput(feed: feed), + ); + } + + Future> getFeedGenerators({ + required List feeds, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetFeedGenerators, + headers: $headers, + service: $service, + parameters: FeedGetFeedGeneratorsInput(feeds: feeds), + ); + } + + Future> getPostThread({ + required AtUri uri, + int depth = 6, + int parentHeight = 80, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetPostThread, + headers: $headers, + service: $service, + parameters: FeedGetPostThreadInput(uri: uri, depth: depth, parentHeight: parentHeight), + ); + } + + Future> getPosts({ + required List uris, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetPosts, + headers: $headers, + service: $service, + parameters: FeedGetPostsInput(uris: uris), + ); + } + + Future> searchPosts({ + required String q, + FeedSearchPostsSort? sort, + String? since, + String? until, + String? mentions, + String? author, + String? lang, + String? domain, + String? url, + List? tag, + int limit = 25, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedSearchPosts, + headers: $headers, + service: $service, + parameters: FeedSearchPostsInput( + q: q, + sort: sort, + since: since, + until: until, + mentions: mentions, + author: author, + lang: lang, + domain: domain, + url: url, + tag: tag, + limit: limit, + cursor: cursor, + ), + ); + } + + Future> getActorLikes({ + required String actor, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetActorLikes, + headers: $headers, + service: $service, + parameters: FeedGetActorLikesInput(actor: actor, limit: limit, cursor: cursor), + ); + } + + Future> getListFeed({ + required AtUri list, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetListFeed, + headers: $headers, + service: $service, + parameters: FeedGetListFeedInput(list: list, limit: limit, cursor: cursor), + ); + } + + Future> getLikes({ + required AtUri uri, + String? cid, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetLikes, + headers: $headers, + service: $service, + parameters: FeedGetLikesInput(uri: uri, cid: cid, limit: limit, cursor: cursor), + ); + } + + Future> getRepostedBy({ + required AtUri uri, + String? cid, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetRepostedBy, + headers: $headers, + service: $service, + parameters: FeedGetRepostedByInput(uri: uri, cid: cid, limit: limit, cursor: cursor), + ); + } + + Future> getQuotes({ + required AtUri uri, + String? cid, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyFeedGetQuotes, + headers: $headers, + service: $service, + parameters: FeedGetQuotesInput(uri: uri, cid: cid, limit: limit, cursor: cursor), + ); + } +} diff --git a/lib/core/network/services/bluesky_graph_service.dart b/lib/core/network/services/bluesky_graph_service.dart new file mode 100644 index 0000000..52fb51c --- /dev/null +++ b/lib/core/network/services/bluesky_graph_service.dart @@ -0,0 +1,195 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyGraphService { + BlueskyGraphService._(this._client); + + final PoptartClient _client; + + GraphBlockRecordService get block => GraphBlockRecordService._(_client); + GraphFollowRecordService get follow => GraphFollowRecordService._(_client); + GraphListRecordService get list => GraphListRecordService._(_client); + GraphListblockRecordService get listblock => GraphListblockRecordService._(_client); + GraphListitemRecordService get listitem => GraphListitemRecordService._(_client); + GraphStarterpackRecordService get starterpack => GraphStarterpackRecordService._(_client); + + Future> getFollows({ + required String actor, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetFollows, + headers: $headers, + service: $service, + parameters: GraphGetFollowsInput(actor: actor, limit: limit, cursor: cursor), + ); + } + + Future> getFollowers({ + required String actor, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetFollowers, + headers: $headers, + service: $service, + parameters: GraphGetFollowersInput(actor: actor, limit: limit, cursor: cursor), + ); + } + + Future> getLists({ + required String actor, + int limit = 50, + String? cursor, + List? purposes, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetLists, + headers: $headers, + service: $service, + parameters: GraphGetListsInput(actor: actor, limit: limit, cursor: cursor, purposes: purposes), + ); + } + + Future> getList({ + required AtUri list, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetList, + headers: $headers, + service: $service, + parameters: GraphGetListInput(list: list, limit: limit, cursor: cursor), + ); + } + + Future> getListsWithMembership({ + required String actor, + int limit = 50, + String? cursor, + List? purposes, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetListsWithMembership, + headers: $headers, + service: $service, + parameters: GraphGetListsWithMembershipInput(actor: actor, limit: limit, cursor: cursor, purposes: purposes), + ); + } + + Future> getActorStarterPacks({ + required String actor, + int limit = 50, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetActorStarterPacks, + headers: $headers, + service: $service, + parameters: GraphGetActorStarterPacksInput(actor: actor, limit: limit, cursor: cursor), + ); + } + + Future> getStarterPack({ + required AtUri starterPack, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetStarterPack, + headers: $headers, + service: $service, + parameters: GraphGetStarterPackInput(starterPack: starterPack), + ); + } + + Future> getSuggestedFollowsByActor({ + required String actor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphGetSuggestedFollowsByActor, + headers: $headers, + service: $service, + parameters: GraphGetSuggestedFollowsByActorInput(actor: actor), + ); + } + + Future> searchStarterPacks({ + required String q, + int limit = 25, + String? cursor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphSearchStarterPacks, + headers: $headers, + service: $service, + parameters: GraphSearchStarterPacksInput(q: q, limit: limit, cursor: cursor), + ); + } + + Future> muteActor({required String actor, Map? $headers, String? $service}) { + return _client.call( + appBskyGraphMuteActor, + headers: $headers, + service: $service, + input: GraphMuteActorInput(actor: actor), + ); + } + + Future> unmuteActor({ + required String actor, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphUnmuteActor, + headers: $headers, + service: $service, + input: GraphUnmuteActorInput(actor: actor), + ); + } + + Future> muteActorList({ + required AtUri list, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphMuteActorList, + headers: $headers, + service: $service, + input: GraphMuteActorListInput(list: list), + ); + } + + Future> unmuteActorList({ + required AtUri list, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyGraphUnmuteActorList, + headers: $headers, + service: $service, + input: GraphUnmuteActorListInput(list: list), + ); + } +} diff --git a/lib/core/network/services/bluesky_labeler_service.dart b/lib/core/network/services/bluesky_labeler_service.dart new file mode 100644 index 0000000..e281d24 --- /dev/null +++ b/lib/core/network/services/bluesky_labeler_service.dart @@ -0,0 +1,21 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyLabelerService { + BlueskyLabelerService._(this._client); + + final PoptartClient _client; + + Future> getServices({ + required List dids, + bool detailed = false, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyLabelerGetServices, + headers: $headers, + service: $service, + parameters: LabelerGetServicesInput(dids: dids, detailed: detailed), + ); + } +} diff --git a/lib/core/network/services/bluesky_notification_service.dart b/lib/core/network/services/bluesky_notification_service.dart new file mode 100644 index 0000000..da6ee34 --- /dev/null +++ b/lib/core/network/services/bluesky_notification_service.dart @@ -0,0 +1,89 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyNotificationService { + BlueskyNotificationService._(this._client); + + final PoptartClient _client; + + Future> listNotifications({ + List? reasons, + int limit = 50, + bool? priority, + String? cursor, + DateTime? seenAt, + Map? $headers, + String? $service, + }) => _client.call( + appBskyNotificationListNotifications, + headers: $headers, + service: $service, + parameters: + _coerceDescriptorParameters( + appBskyNotificationListNotifications.methodDescriptor, + NotificationListNotificationsInput( + reasons: reasons, + limit: limit, + priority: priority, + cursor: cursor, + seenAt: seenAt, + ), + ) + as NotificationListNotificationsInput, + ); + + Future> getUnreadCount({ + Map? $headers, + String? $service, + }) => _client.call(appBskyNotificationGetUnreadCount, headers: $headers, service: $service); + + Future> updateSeen({ + required DateTime seenAt, + Map? $headers, + String? $service, + }) => _client.call( + appBskyNotificationUpdateSeen, + headers: $headers, + service: $service, + input: + _coerceDescriptorInput( + appBskyNotificationUpdateSeen.methodDescriptor, + NotificationUpdateSeenInput(seenAt: seenAt), + ) + as NotificationUpdateSeenInput, + ); + + Future> registerPush({ + required String serviceDid, + required String token, + required NotificationRegisterPushPlatform platform, + required String appId, + bool? ageRestricted, + Map? $headers, + String? $service, + }) => _client.call( + appBskyNotificationRegisterPush, + headers: $headers, + service: $service, + input: NotificationRegisterPushInput( + serviceDid: serviceDid, + token: token, + platform: platform, + appId: appId, + ageRestricted: ageRestricted, + ), + ); + + Future> unregisterPush({ + required String serviceDid, + required String token, + required NotificationUnregisterPushPlatform platform, + required String appId, + Map? $headers, + String? $service, + }) => _client.call( + appBskyNotificationUnregisterPush, + headers: $headers, + service: $service, + input: NotificationUnregisterPushInput(serviceDid: serviceDid, token: token, platform: platform, appId: appId), + ); +} diff --git a/lib/core/network/services/bluesky_unspecced_service.dart b/lib/core/network/services/bluesky_unspecced_service.dart new file mode 100644 index 0000000..2bbca11 --- /dev/null +++ b/lib/core/network/services/bluesky_unspecced_service.dart @@ -0,0 +1,49 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyUnspeccedService { + BlueskyUnspeccedService._(this._client); + + final PoptartClient _client; + + Future> getTrendingTopics({ + String? viewer, + int limit = 10, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyUnspeccedGetTrendingTopics, + headers: $headers, + service: $service, + parameters: UnspeccedGetTrendingTopicsInput(viewer: viewer, limit: limit), + ); + } + + Future> getTrends({ + int limit = 10, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyUnspeccedGetTrends, + headers: $headers, + service: $service, + parameters: UnspeccedGetTrendsInput(limit: limit), + ); + } + + Future> getPopularFeedGenerators({ + int limit = 50, + String? cursor, + String? query, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyUnspeccedGetPopularFeedGenerators, + headers: $headers, + service: $service, + parameters: UnspeccedGetPopularFeedGeneratorsInput(limit: limit, cursor: cursor, query: query), + ); + } +} diff --git a/lib/core/network/services/bluesky_video_service.dart b/lib/core/network/services/bluesky_video_service.dart new file mode 100644 index 0000000..c732034 --- /dev/null +++ b/lib/core/network/services/bluesky_video_service.dart @@ -0,0 +1,52 @@ +part of '../poptart_client_adapter.dart'; + +class BlueskyVideoService { + BlueskyVideoService._(this._client); + + static const _videoServiceDid = 'did:web:video.bsky.app'; + + final PoptartClient _client; + + Future> uploadVideo({ + required Uint8List bytes, + Map? $headers, + String? $service, + }) { + return _client.call(appBskyVideoUploadVideo, headers: $headers, service: $service, input: bytes); + } + + Future> getJobStatus({ + required String jobId, + Map? $headers, + String? $service, + }) { + return _client.call( + appBskyVideoGetJobStatus, + headers: $headers, + service: $service, + parameters: VideoGetJobStatusInput(jobId: jobId), + ); + } + + Future> getUploadLimits({Map? $headers, String? $service}) { + return _client.call(appBskyVideoGetUploadLimits, headers: $headers, service: $service); + } + + Future> getUploadLimitsAuth({ + int? exp, + Map? $headers, + String? $service, + }) { + return AtProtoServerService._(_client).getServiceAuth( + aud: _videoServiceDid, + exp: exp, + lxm: 'app.bsky.video.getUploadLimits', + $headers: $headers, + $service: $service, + ); + } + + Future> getUploadLimitsWithAuthToken(String authToken, {String? $service}) { + return getUploadLimits($headers: {'Authorization': 'Bearer $authToken'}, $service: $service); + } +} diff --git a/lib/core/network/services/current_repo_record_service.dart b/lib/core/network/services/current_repo_record_service.dart new file mode 100644 index 0000000..78be6cf --- /dev/null +++ b/lib/core/network/services/current_repo_record_service.dart @@ -0,0 +1,62 @@ +part of '../poptart_client_adapter.dart'; + +abstract class _CurrentRepoRecordService { + _CurrentRepoRecordService(this._client, this._collection); + + final PoptartClient _client; + final String _collection; + + Future> createRecord({ + required Map record, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => AtProtoRepoService._(_client).createRecord( + repo: _repoDid(_client), + collection: _collection, + rkey: rkey, + validate: validate, + record: record, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); + + Future> putRecord({ + required String rkey, + required Map record, + bool? validate, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => AtProtoRepoService._(_client).putRecord( + repo: _repoDid(_client), + collection: _collection, + rkey: rkey, + validate: validate, + record: record, + swapRecord: swapRecord, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); + + Future> delete({ + required String rkey, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => AtProtoRepoService._(_client).deleteRecord( + repo: _repoDid(_client), + collection: _collection, + rkey: rkey, + swapRecord: swapRecord, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} diff --git a/lib/core/network/services/feed_record_services.dart b/lib/core/network/services/feed_record_services.dart new file mode 100644 index 0000000..9191aa7 --- /dev/null +++ b/lib/core/network/services/feed_record_services.dart @@ -0,0 +1,75 @@ +part of '../poptart_client_adapter.dart'; + +class FeedLikeRecordService extends _CurrentRepoRecordService { + FeedLikeRecordService._(PoptartClient client) : super(client, 'app.bsky.feed.like'); + + Future> create({ + required RepoStrongRef subject, + DateTime? createdAt, + RepoStrongRef? via, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: FeedLikeRecord( + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + via: via, + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class FeedRepostRecordService extends _CurrentRepoRecordService { + FeedRepostRecordService._(PoptartClient client) : super(client, 'app.bsky.feed.repost'); + + Future> create({ + required RepoStrongRef subject, + DateTime? createdAt, + RepoStrongRef? via, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: FeedRepostRecord( + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + via: via, + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class FeedPostRecordService extends _CurrentRepoRecordService { + FeedPostRecordService._(PoptartClient client) : super(client, 'app.bsky.feed.post'); + + Future> put({ + required String rkey, + required FeedPostRecord record, + bool? validate, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => putRecord( + rkey: rkey, + record: record.toJson(), + validate: validate, + swapRecord: swapRecord, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} diff --git a/lib/core/network/services/graph_record_services.dart b/lib/core/network/services/graph_record_services.dart new file mode 100644 index 0000000..d12766e --- /dev/null +++ b/lib/core/network/services/graph_record_services.dart @@ -0,0 +1,225 @@ +part of '../poptart_client_adapter.dart'; + +class GraphBlockRecordService extends _CurrentRepoRecordService { + GraphBlockRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.block'); + + Future> create({ + required String subject, + DateTime? createdAt, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphBlockRecord( + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class GraphListRecordService extends _CurrentRepoRecordService { + GraphListRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.list'); + + Future> create({ + required ListPurpose purpose, + required String name, + String? description, + List? descriptionFacets, + Blob? avatar, + UGraphListLabels? labels, + DateTime? createdAt, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphListRecord( + purpose: purpose, + name: name, + description: description, + descriptionFacets: descriptionFacets, + avatar: avatar, + labels: labels, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); + + Future> put({ + required String rkey, + required ListPurpose purpose, + required String name, + String? description, + List? descriptionFacets, + Blob? avatar, + UGraphListLabels? labels, + DateTime? createdAt, + bool? validate, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => putRecord( + rkey: rkey, + record: GraphListRecord( + purpose: purpose, + name: name, + description: description, + descriptionFacets: descriptionFacets, + avatar: avatar, + labels: labels, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + validate: validate, + swapRecord: swapRecord, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class GraphFollowRecordService extends _CurrentRepoRecordService { + GraphFollowRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.follow'); + + Future> create({ + required String subject, + DateTime? createdAt, + RepoStrongRef? via, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphFollowRecord( + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + via: via, + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class GraphListblockRecordService extends _CurrentRepoRecordService { + GraphListblockRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.listblock'); + + Future> create({ + required AtUri subject, + DateTime? createdAt, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphListblockRecord( + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class GraphListitemRecordService extends _CurrentRepoRecordService { + GraphListitemRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.listitem'); + + Future> create({ + required AtUri list, + required String subject, + DateTime? createdAt, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphListitemRecord( + list: list, + subject: subject, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} + +class GraphStarterpackRecordService extends _CurrentRepoRecordService { + GraphStarterpackRecordService._(PoptartClient client) : super(client, 'app.bsky.graph.starterpack'); + + Future> create({ + required String name, + required AtUri list, + String? description, + List? feeds, + DateTime? createdAt, + String? rkey, + bool? validate, + String? swapCommit, + Map? $headers, + String? $service, + }) => createRecord( + record: GraphStarterpackRecord( + name: name, + list: list, + description: description, + feeds: feeds, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + rkey: rkey, + validate: validate, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); + + Future> put({ + required String rkey, + required String name, + required AtUri list, + String? description, + List? feeds, + DateTime? createdAt, + bool? validate, + String? swapRecord, + String? swapCommit, + Map? $headers, + String? $service, + }) => putRecord( + rkey: rkey, + record: GraphStarterpackRecord( + name: name, + list: list, + description: description, + feeds: feeds, + createdAt: canonicalAtProtoDateTime(createdAt ?? DateTime.now()), + ).toJson(), + validate: validate, + swapRecord: swapRecord, + swapCommit: swapCommit, + $headers: $headers, + $service: $service, + ); +} diff --git a/lib/core/network/services/poptart_helpers.dart b/lib/core/network/services/poptart_helpers.dart new file mode 100644 index 0000000..6184dad --- /dev/null +++ b/lib/core/network/services/poptart_helpers.dart @@ -0,0 +1,105 @@ +part of '../poptart_client_adapter.dart'; + +XRPCResponse _sessionResponse(final XRPCResponse response, final Session session) { + return XRPCResponse( + headers: response.headers, + status: response.status, + request: response.request, + rateLimit: response.rateLimit, + data: session, + ); +} + +Session _sessionFromCreateSessionOutput(final ServerCreateSessionOutput output) { + return Session.fromJson(output.toJson()); +} + +Session _sessionFromRefreshSessionOutput(final ServerRefreshSessionOutput output) { + return Session.fromJson(output.toJson()); +} + +dynamic _coerceDescriptorParameters(XRPCMethodDescriptor descriptor, Object? parameters) { + if (parameters == null) { + return null; + } + + final normalized = _normalizeDescriptorJson(value: parameters, encoder: descriptor.parametersToJson); + if (normalized == null) { + return parameters; + } + + final converter = descriptor.parametersFromJson; + if (converter != null) { + return converter.call(normalized); + } + return normalized.isEmpty ? null : normalized; +} + +dynamic _coerceDescriptorInput(XRPCMethodDescriptor descriptor, Object? input) { + if (input == null) { + return null; + } + + final normalized = _normalizeDescriptorJson(value: input, encoder: descriptor.inputToJson); + if (normalized == null) { + return input; + } + + final converter = descriptor.inputFromJson; + if (converter != null) { + return converter.call(normalized); + } + return normalized.isEmpty ? null : normalized; +} + +Map? _normalizeDescriptorJson({ + required Object value, + required Map Function(dynamic value)? encoder, +}) { + if (value is Map) { + return _normalizeJson(value) as Map; + } + if (encoder == null) { + return null; + } + return _normalizeJson(encoder.call(value)) as Map; +} + +dynamic _normalizeJson(dynamic value, {String? key}) { + if (value == null) return value; + if (value is String) { + return _isDateTimeJsonField(key) ? formatAtProtoDateTimeString(value) ?? value : value; + } + if (value is num || value is bool) return value; + if (value is DateTime) return formatAtProtoDateTime(value); + if (value is AtUri || value is NSID) return value.toString(); + if (value is Blob || value is BlobRef) return value.toJson(); + if (value is List) return value.map((item) => _normalizeJson(item)).toList(growable: false); + if (value is Map) { + return value.map((key, val) { + final stringKey = key.toString(); + return MapEntry(stringKey, _normalizeJson(val, key: stringKey)); + }); + } + final dynamic dynamicValue = value; + try { + return _normalizeJson(dynamicValue.toJson()); + } catch (error, stackTrace) { + developer.log( + 'Falling back to string serialization for ${value.runtimeType}.', + name: 'lazurite.network.poptart', + error: error, + stackTrace: stackTrace, + level: 500, + ); + return value.toString(); + } +} + +bool _isDateTimeJsonField(String? key) => key != null && key.endsWith('At'); + +String _repoDid(PoptartClient client) { + return client.session?.did ?? + client.oAuthSession?.sub ?? + (throw StateError('Authenticated repo DID is unavailable.')); +} diff --git a/lib/core/scheduler/post_scheduler.dart b/lib/core/scheduler/post_scheduler.dart index 01a3c1a..8066192 100644 --- a/lib/core/scheduler/post_scheduler.dart +++ b/lib/core/scheduler/post_scheduler.dart @@ -1,9 +1,14 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' show Blob; -import 'package:poptart_lex/app/bsky/video/defs.dart' show KnownJobStatusState; +import 'package:poptart_core/poptart_core.dart' show AtUri, Blob; +import 'package:poptart_lex/app/bsky/embed/defs.dart' as embed_defs; +import 'package:poptart_lex/app/bsky/embed/images.dart'; +import 'package:poptart_lex/app/bsky/embed/video.dart'; +import 'package:poptart_lex/app/bsky/feed/post.dart'; +import 'package:poptart_lex/app/bsky/richtext/facet.dart'; +import 'package:poptart_lex/app/bsky/video/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; import 'package:flutter/widgets.dart'; import 'package:lazurite/core/database/app_database.dart'; @@ -11,6 +16,7 @@ import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; +import 'package:lazurite/features/compose/data/draft_embed_payload.dart'; import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; import 'package:workmanager/workmanager.dart'; @@ -75,41 +81,39 @@ Future _submitScheduledDraft(int draftId) async { final composeRepo = ComposeRepository(bluesky: bluesky); - final facets = >[]; + final facets = []; for (final entity in BlueskyText(draft.content).entities) { try { - final facet = await entity.toFacet().timeout( + final facetJson = await entity.toFacet().timeout( const Duration(seconds: 5), onTimeout: () { log.w('Scheduled post: timeout resolving facet for "${entity.value}"'); return {}; }, ); - if (facet.isNotEmpty) facets.add(facet); + if (facetJson.isNotEmpty) { + facets.add(const RichtextFacetConverter().fromJson(Map.from(facetJson))); + } } catch (e) { log.w('Scheduled post: could not resolve facet for "${entity.value}": $e'); } } - Map? embed; + UFeedPostEmbed? embed; - if (draft.embedJson != null) { - final decoded = jsonDecode(draft.embedJson!) as Map; - final type = decoded['type'] as String?; - - if (type == 'images') { - embed = await _buildImageEmbed(composeRepo, decoded); - } else if (type == 'video') { - embed = await _buildVideoEmbed(composeRepo, decoded); - } + final embedPayload = DraftEmbedPayload.tryDecode(draft.embedJson); + if (embedPayload is DraftImagesEmbedPayload) { + embed = await _buildImageEmbed(composeRepo, embedPayload); + } else if (embedPayload is DraftVideoEmbedPayload) { + embed = await _buildVideoEmbed(composeRepo, embedPayload); } - Map? reply; + ReplyRef? reply; if (draft.replyUri != null && draft.replyCid != null) { - reply = { - 'parent': {'uri': draft.replyUri, 'cid': draft.replyCid}, - 'root': {'uri': draft.rootUri ?? draft.replyUri, 'cid': draft.rootCid ?? draft.replyCid}, - }; + reply = ReplyRef( + parent: RepoStrongRef(uri: AtUri.parse(draft.replyUri!), cid: draft.replyCid!), + root: RepoStrongRef(uri: AtUri.parse(draft.rootUri ?? draft.replyUri!), cid: draft.rootCid ?? draft.replyCid!), + ); } final success = await composeRepo.createPost( @@ -131,11 +135,11 @@ Future _submitScheduledDraft(int draftId) async { } } -/// Uploads images from [embedJson] `{ "paths": [...], "altTexts": [...] }`. -Future?> _buildImageEmbed(ComposeRepository repo, Map embedJson) async { - final paths = (embedJson['paths'] as List? ?? []).cast(); - final alts = (embedJson['altTexts'] as List? ?? []).cast(); - final images = >[]; +/// Uploads images from a draft embed payload. +Future _buildImageEmbed(ComposeRepository repo, DraftImagesEmbedPayload payload) async { + final paths = payload.paths; + final alts = payload.altTexts; + final images = []; for (var i = 0; i < paths.length; i++) { final file = File(paths[i]); @@ -157,30 +161,27 @@ Future?> _buildImageEmbed(ComposeRepository repo, Map{'image': blob.toJson(), 'alt': altText}; - + embed_defs.AspectRatio? aspectRatio; try { final dims = await readImageDimensions(bytes.toList()); if (dims != null) { - entry['aspectRatio'] = {'width': dims.width, 'height': dims.height}; + aspectRatio = embed_defs.AspectRatio(width: dims.width, height: dims.height); } } catch (_) { log.w('Scheduled post: could not read image dimensions for ${paths[i]}'); } - images.add(entry); + images.add(EmbedImagesImage(image: blob, alt: altText, aspectRatio: aspectRatio)); } if (images.isEmpty) return null; - return {'\$type': 'app.bsky.embed.images', 'images': images}; + return UFeedPostEmbed.embedImages(data: EmbedImages(images: images)); } /// Re-uploads a video from its local path and polls the processing job. -Future?> _buildVideoEmbed(ComposeRepository repo, Map embedJson) async { - final path = embedJson['path'] as String?; - final altText = (embedJson['alt'] as String?) ?? ''; - - if (path == null) return null; +Future _buildVideoEmbed(ComposeRepository repo, DraftVideoEmbedPayload payload) async { + final path = payload.path; + final altText = payload.alt; final file = File(path); if (!file.existsSync()) { @@ -199,7 +200,9 @@ Future?> _buildVideoEmbed(ComposeRepository repo, Map _pollVideoJob(ComposeRepository repo, String jobId) async { final status = await repo.getJobStatus(jobId); if (status == null) continue; - final knownState = (status as dynamic).state.knownValue; + final knownState = status.state.knownValue; if (knownState == KnownJobStatusState.jOB_STATE_COMPLETED && status.blob != null) { - return status.blob as Blob; + return status.blob; } if (knownState == KnownJobStatusState.jOB_STATE_FAILED) { - final error = (status as dynamic).error as String?; - log.e('Scheduled post: video processing failed — ${error ?? 'unknown error'}'); + log.e('Scheduled post: video processing failed — ${status.error ?? 'unknown error'}'); return null; } } catch (e) { diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 043ddc1..9527a20 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'dart:convert'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -271,12 +270,7 @@ class _LoginScreenState extends State { return null; } - final json = jsonDecode(profile.payload); - if (json is! Map) { - return null; - } - - final avatar = json['avatar']; + final avatar = PoptartCacheCodecs.profileViewDetailed.decode(profile.payload).avatar; if (avatar is String && avatar.isNotEmpty) { return avatar; } diff --git a/lib/features/compose/bloc/compose_bloc.dart b/lib/features/compose/bloc/compose_bloc.dart index 2626f7f..77c9fe1 100644 --- a/lib/features/compose/bloc/compose_bloc.dart +++ b/lib/features/compose/bloc/compose_bloc.dart @@ -1,10 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; -import 'package:poptart_core/poptart_core.dart' show AtUri, Blob, BlobRef, XRPCException; -import 'package:poptart_lex/app/bsky/video/defs.dart' show KnownJobStatusState; +import 'package:poptart_lex/app/bsky/embed/defs.dart' as embed_defs; +import 'package:poptart_lex/app/bsky/embed/external.dart'; +import 'package:poptart_lex/app/bsky/embed/images.dart'; +import 'package:poptart_lex/app/bsky/embed/record.dart'; +import 'package:poptart_lex/app/bsky/embed/record_with_media.dart'; +import 'package:poptart_lex/app/bsky/embed/video.dart'; +import 'package:poptart_lex/app/bsky/feed/post.dart'; +import 'package:poptart_lex/app/bsky/richtext/facet.dart'; +import 'package:poptart_lex/app/bsky/video/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/get_record.dart'; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; import 'package:characters/characters.dart'; import 'package:drift/drift.dart'; @@ -13,9 +21,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/scheduler/post_scheduler.dart'; +import 'package:lazurite/features/compose/data/draft_embed_payload.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/data/link_preview_service.dart'; @@ -237,7 +247,7 @@ class ComposeBloc extends Bloc { Future _onDraftSaved(DraftSaved event, Emitter emit) async { emit(state.copyWith(isSavingDraft: true)); try { - final embedJson = _buildEmbedJson(); + final embedPayload = _buildEmbedPayload(); final draft = DraftsCompanion( id: state.draftId != null ? Value(state.draftId!) : const Value.absent(), accountDid: Value(_accountDid), @@ -246,9 +256,9 @@ class ComposeBloc extends Bloc { replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), - embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), + embedJson: embedPayload != null ? Value(embedPayload.encode()) : const Value.absent(), mediaPaths: state.mediaAttachments.isNotEmpty - ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) + ? Value(DraftEmbedPayload.encodeMediaPaths(state.mediaAttachments.map((m) => m.localPath))) : const Value.absent(), scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), updatedAt: Value(DateTime.now()), @@ -268,20 +278,15 @@ class ComposeBloc extends Bloc { List attachments = []; - if (draft.embedJson != null) { + final embedPayload = DraftEmbedPayload.tryDecode(draft.embedJson); + if (embedPayload is DraftImagesEmbedPayload) { try { - final decoded = jsonDecode(draft.embedJson!) as Map; - final type = decoded['type'] as String?; - if (type == 'images') { - final paths = decoded['paths'] as List? ?? []; - final alts = decoded['altTexts'] as List? ?? []; - attachments = paths.asMap().entries.where((e) => File(e.value as String).existsSync()).map((e) { - return MediaAttachment( - localPath: e.value as String, - altText: e.key < alts.length ? (alts[e.key] as String? ?? '') : '', - ); - }).toList(); - } + attachments = embedPayload.paths.asMap().entries.where((e) => File(e.value).existsSync()).map((e) { + return MediaAttachment( + localPath: e.value, + altText: e.key < embedPayload.altTexts.length ? embedPayload.altTexts[e.key] : '', + ); + }).toList(); } catch (e) { log.w('Failed to parse embedJson from draft', error: e); } @@ -289,12 +294,9 @@ class ComposeBloc extends Bloc { if (attachments.isEmpty && draft.mediaPaths != null) { try { - final paths = jsonDecode(draft.mediaPaths!) as List; - attachments = paths - .whereType() - .where((path) => File(path).existsSync()) - .map((path) => MediaAttachment(localPath: path)) - .toList(); + attachments = DraftEmbedPayload.decodeMediaPaths( + draft.mediaPaths!, + ).where((path) => File(path).existsSync()).map((path) => MediaAttachment(localPath: path)).toList(); } catch (e) { log.w('Failed to parse mediaPaths from draft', error: e); } @@ -440,7 +442,7 @@ class ComposeBloc extends Bloc { } if (state.scheduledAt != null && state.scheduledAt!.isAfter(DateTime.now())) { - final embedJson = _buildEmbedJson(); + final embedPayload = _buildEmbedPayload(); final draft = DraftsCompanion( accountDid: Value(_accountDid), content: Value(state.text), @@ -448,9 +450,9 @@ class ComposeBloc extends Bloc { replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), - embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), + embedJson: embedPayload != null ? Value(embedPayload.encode()) : const Value.absent(), mediaPaths: state.mediaAttachments.isNotEmpty - ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) + ? Value(DraftEmbedPayload.encodeMediaPaths(state.mediaAttachments.map((m) => m.localPath))) : const Value.absent(), scheduledAt: Value(state.scheduledAt!), updatedAt: Value(DateTime.now()), @@ -461,7 +463,7 @@ class ComposeBloc extends Bloc { return; } - Map? mediaEmbed; + UFeedPostEmbed? mediaEmbed; if (state.mediaAttachments.isNotEmpty) { final uploaded = <_UploadedImage>[]; @@ -495,19 +497,26 @@ class ComposeBloc extends Bloc { ); } - mediaEmbed = { - '\$type': 'app.bsky.embed.images', - 'images': uploaded.map((img) { - final entry = {'image': img.blob.toJson(), 'alt': img.altText}; - if (img.width != null && img.height != null) { - entry['aspectRatio'] = {'width': img.width, 'height': img.height}; - } - return entry; - }).toList(), - }; + mediaEmbed = UFeedPostEmbed.embedImages( + data: EmbedImages( + images: uploaded + .map( + (img) => EmbedImagesImage( + image: img.blob, + alt: img.altText, + aspectRatio: img.width != null && img.height != null + ? embed_defs.AspectRatio(width: img.width!, height: img.height!) + : null, + ), + ) + .toList(growable: false), + ), + ); } else if (state.videoAttachment?.isReady == true) { final blob = state.videoAttachment!.blob!; - mediaEmbed = {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': state.videoAttachment!.altText}; + mediaEmbed = UFeedPostEmbed.embedVideo( + data: EmbedVideo(video: blob, alt: state.videoAttachment!.altText), + ); } else { final firstLink = LinkPreviewService.firstLink(state.text); if (firstLink != null && firstLink != event.suppressedLinkUri) { @@ -515,28 +524,23 @@ class ComposeBloc extends Bloc { } } - Map? embed; + UFeedPostEmbed? embed; if (state.quoteUri != null && state.quoteCid != null) { + final record = EmbedRecord( + record: RepoStrongRef(uri: AtUri.parse(state.quoteUri!), cid: state.quoteCid!), + ); if (mediaEmbed != null) { - embed = { - r'$type': 'app.bsky.embed.recordWithMedia', - 'record': { - r'$type': 'app.bsky.embed.record', - 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, - }, - 'media': mediaEmbed, - }; + embed = UFeedPostEmbed.embedRecordWithMedia( + data: EmbedRecordWithMedia(record: record, media: _recordWithMediaMedia(mediaEmbed)), + ); } else { - embed = { - r'$type': 'app.bsky.embed.record', - 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, - }; + embed = UFeedPostEmbed.embedRecord(data: record); } } else { embed = mediaEmbed; } - Map? reply; + ReplyRef? reply; if (state.replyParentUri != null && state.replyParentCid != null) { final fallbackRootUri = state.replyRootUri ?? state.replyParentUri!; final fallbackRootCid = state.replyRootCid ?? state.replyParentCid!; @@ -551,10 +555,10 @@ class ComposeBloc extends Bloc { final rootUri = resolvedReplyRefs?.rootUri ?? fallbackRootUri; final rootCid = resolvedReplyRefs?.rootCid ?? fallbackRootCid; - reply = { - 'parent': {'uri': state.replyParentUri, 'cid': parentCid}, - 'root': {'uri': rootUri, 'cid': rootCid}, - }; + reply = ReplyRef( + parent: RepoStrongRef(uri: AtUri.parse(state.replyParentUri!), cid: parentCid), + root: RepoStrongRef(uri: AtUri.parse(rootUri), cid: rootCid), + ); } final success = await _composeRepository.createPost( @@ -583,20 +587,22 @@ class ComposeBloc extends Bloc { } } - Future>> _collectFacets() async { + Future> _collectFacets() async { final blueskyText = BlueskyText(state.text); - final facets = >[]; + final facets = []; for (final entity in blueskyText.entities) { try { - final facet = await entity.toFacet().timeout( + final facetJson = await entity.toFacet().timeout( const Duration(seconds: 5), onTimeout: () { log.w('Timeout resolving @${entity.value}; facet dropped.'); return {}; }, ); - if (facet.isNotEmpty) facets.add(facet); + if (facetJson.isNotEmpty) { + facets.add(const RichtextFacetConverter().fromJson(Map.from(facetJson))); + } } catch (e) { log.w('Could not resolve facet for "${entity.value}": $e'); } @@ -607,7 +613,7 @@ class ComposeBloc extends Bloc { Future _saveFailedSubmissionAsDraft(Emitter emit, Object error) async { try { - final embedJson = _buildEmbedJson(); + final embedPayload = _buildEmbedPayload(); final draft = DraftsCompanion( accountDid: Value(_accountDid), content: Value(state.text), @@ -615,9 +621,9 @@ class ComposeBloc extends Bloc { replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), - embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), + embedJson: embedPayload != null ? Value(embedPayload.encode()) : const Value.absent(), mediaPaths: state.mediaAttachments.isNotEmpty - ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) + ? Value(DraftEmbedPayload.encodeMediaPaths(state.mediaAttachments.map((m) => m.localPath))) : const Value.absent(), scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), updatedAt: Value(DateTime.now()), @@ -643,20 +649,33 @@ class ComposeBloc extends Bloc { ); } - Map? _buildEmbedJson() { + DraftEmbedPayload? _buildEmbedPayload() { if (state.mediaAttachments.isNotEmpty) { - return { - 'type': 'images', - 'paths': state.mediaAttachments.map((m) => m.localPath).toList(), - 'altTexts': state.mediaAttachments.map((m) => m.altText).toList(), - }; + return DraftEmbedPayload.images( + paths: state.mediaAttachments.map((m) => m.localPath).toList(growable: false), + altTexts: state.mediaAttachments.map((m) => m.altText).toList(growable: false), + ); } if (state.videoAttachment != null) { - return {'type': 'video', 'path': state.videoAttachment!.localPath, 'alt': state.videoAttachment!.altText}; + return DraftEmbedPayload.video(path: state.videoAttachment!.localPath, alt: state.videoAttachment!.altText); } return null; } + UEmbedRecordWithMediaMedia _recordWithMediaMedia(UFeedPostEmbed embed) { + if (embed.isEmbedImages) { + return UEmbedRecordWithMediaMedia.embedImages(data: embed.embedImages!); + } + if (embed.isEmbedVideo) { + return UEmbedRecordWithMediaMedia.embedVideo(data: embed.embedVideo!); + } + if (embed.isEmbedExternal) { + return UEmbedRecordWithMediaMedia.embedExternal(data: embed.embedExternal!); + } + + return UEmbedRecordWithMediaMedia.unknown(data: embed.toJson()); + } + /// Returns MIME type from magic bytes, or null if not an accepted image type. static String? _detectImageMime(List bytes) { if (bytes.length < 12) return null; @@ -699,14 +718,14 @@ class EditPostResult { class ComposeRepository { ComposeRepository({ - required dynamic bluesky, + required Bluesky bluesky, LinkPreviewService? linkPreviewService, ActorRepositoryServiceResolver? actorRepositoryServiceResolver, Future Function()? onUnauthorized, - dynamic Function(AuthTokens tokens)? blueskyClientFactory, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, }) : _actorRepoResolver = actorRepositoryServiceResolver ?? ActorRepositoryServiceResolver(), _linkPreviewService = linkPreviewService ?? LinkPreviewService() { - _authRecovery = UnauthorizedRecoveryRunner( + _authRecovery = UnauthorizedRecoveryRunner( initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, @@ -716,8 +735,8 @@ class ComposeRepository { ); } - late final UnauthorizedRecoveryRunner _authRecovery; - dynamic get _bluesky => _authRecovery.client; + late final UnauthorizedRecoveryRunner _authRecovery; + Bluesky get _bluesky => _authRecovery.client; final LinkPreviewService _linkPreviewService; final ActorRepositoryServiceResolver _actorRepoResolver; @@ -750,7 +769,7 @@ class ComposeRepository { } } - Future getJobStatus(String jobId) async { + Future getJobStatus(String jobId) async { try { final response = await _authRecovery.run((client) => client.video.getJobStatus(jobId: jobId)); return response.data.jobStatus; @@ -775,24 +794,27 @@ class ComposeRepository { Future createPost({ required String text, - required List> facets, - Map? embed, - Map? reply, + required List facets, + UFeedPostEmbed? embed, + ReplyRef? reply, required String repo, }) async { try { - final record = { - '\$type': 'app.bsky.feed.post', - 'text': text, - 'createdAt': DateTime.now().toUtc().toIso8601String(), - 'langs': ['en'], - }; - if (facets.isNotEmpty) record['facets'] = facets; - if (embed != null) record['embed'] = embed; - if (reply != null) record['reply'] = reply; + final record = FeedPostRecord( + text: text, + facets: facets.isEmpty ? null : facets, + embed: embed, + reply: reply, + langs: const ['en'], + createdAt: DateTime.now().toUtc(), + ); await _authRecovery.run( - (client) => client.atproto.repo.createRecord(repo: repo, collection: 'app.bsky.feed.post', record: record), + (client) => client.atproto.repo.createRecord( + repo: repo, + collection: 'app.bsky.feed.post', + record: _postRecordJson(record), + ), ); return true; } catch (e, stackTrace) { @@ -810,23 +832,31 @@ class ComposeRepository { } } - Future?> buildExternalEmbedFromLink(String rawUrl) async { + Future buildExternalEmbedFromLink(String rawUrl) async { final preview = await fetchLinkPreview(rawUrl); if (preview == null) { return null; } - final external = {'uri': preview.uri, 'title': preview.title, 'description': preview.description}; - final thumbUrl = preview.thumbnailUrl; + Blob? thumbBlob; if (thumbUrl != null && thumbUrl.isNotEmpty) { final thumb = await _uploadExternalThumb(thumbUrl); if (thumb != null) { - external['thumb'] = thumb.toJson(); + thumbBlob = thumb; } } - return {r'$type': 'app.bsky.embed.external', 'external': external}; + return UFeedPostEmbed.embedExternal( + data: EmbedExternal( + external: EmbedExternalExternal( + uri: preview.uri, + title: preview.title, + description: preview.description, + thumb: thumbBlob, + ), + ), + ); } Future<({String parentCid, String rootUri, String rootCid})?> resolveReplyReferences({ @@ -843,28 +873,20 @@ class ComposeRepository { rkey: parentAtUri.rkey, ); - final latestParentCidRaw = parent.data.cid; - final latestParentCid = latestParentCidRaw is String && latestParentCidRaw.isNotEmpty - ? latestParentCidRaw - : parentCid; - final parentValue = parent.data.value; - if (parentValue is! Map) { + final latestParentCid = parent.data.cid?.isNotEmpty == true ? parent.data.cid! : parentCid; + final parentRecord = _parsePostRecord(parent.data.value); + if (parentRecord == null) { return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); } - final parentReply = parentValue['reply']; - if (parentReply is! Map) { + final parentReply = parentRecord.reply; + if (parentReply == null) { return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); } - final rootRef = parentReply['root']; - if (rootRef is! Map) { - return (parentCid: latestParentCid, rootUri: fallbackRootUri, rootCid: fallbackRootCid); - } - - final rootUri = rootRef['uri']; - final rootCid = rootRef['cid']; - if (rootUri is String && rootCid is String && rootUri.isNotEmpty && rootCid.isNotEmpty) { + final rootUri = parentReply.root.uri.toString(); + final rootCid = parentReply.root.cid; + if (rootUri.isNotEmpty && rootCid.isNotEmpty) { return (parentCid: latestParentCid, rootUri: rootUri, rootCid: rootCid); } @@ -893,7 +915,7 @@ class ComposeRepository { required String currentCid, required Map originalRecord, required String text, - required List> facets, + required List facets, required String repo, }) async { try { @@ -904,25 +926,15 @@ class ComposeRepository { final latest = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); final latestValue = latest.data.value; - final latestRecord = latestValue is Map ? Map.from(latestValue) : {}; - final baseRecord = latestRecord.isNotEmpty ? latestRecord : originalRecord; - final latestCid = latest.data.cid; - final swapCid = latestCid is String && latestCid.isNotEmpty ? latestCid : currentCid; - final updatedRecord = Map.from(baseRecord); - updatedRecord['text'] = text; - if (facets.isNotEmpty) { - updatedRecord['facets'] = facets; - } else { - updatedRecord.remove('facets'); - } - - final existingCreatedAt = baseRecord['createdAt']; - if (existingCreatedAt is String && existingCreatedAt.trim().isNotEmpty) { - updatedRecord['createdAt'] = existingCreatedAt; - } else { - updatedRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); + final latestRecord = _parsePostRecord(latestValue); + final originalPostRecord = _parsePostRecord(originalRecord); + final baseRecord = latestRecord ?? originalPostRecord; + if (baseRecord == null) { + return const EditPostResult.failure('This post record is malformed. Reopen it and try editing again.'); } - updatedRecord[r'$type'] = 'app.bsky.feed.post'; + final latestCid = latest.data.cid; + final swapCid = latestCid != null && latestCid.isNotEmpty ? latestCid : currentCid; + final updatedRecord = baseRecord.copyWith(text: text, facets: facets.isEmpty ? null : facets); await _authRecovery.run( (client) => @@ -936,7 +948,7 @@ class ComposeRepository { repo: targetRepo, collection: collection, rkey: rkey, - record: updatedRecord, + record: _postRecordJson(updatedRecord), ), ); newCid = created.data.cid; @@ -968,8 +980,7 @@ class ComposeRepository { } final verified = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); - final verifiedValue = verified.data.value; - final persistedText = verifiedValue is Map ? verifiedValue['text'] : null; + final persistedText = verified.data.value['text']; if (persistedText is! String || persistedText != text) { return const EditPostResult.failure( 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.', @@ -1006,12 +1017,10 @@ class ComposeRepository { }) async { try { final response = await _getRecordFromRepo(repo: repo, collection: collection, rkey: rkey); - final value = response.data.value; - if (value is! Map) { + if (!FeedPostRecord.validate(response.data.value)) { return null; } - final cid = response.data.cid; - return (value: Map.from(value), cid: cid is String ? cid : null); + return (value: response.data.value, cid: response.data.cid); } on XRPCException catch (e, stackTrace) { final errorCode = e.response.data.error; if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { @@ -1029,19 +1038,16 @@ class ComposeRepository { required String repo, required String collection, required String rkey, - required Map originalRecord, + required FeedPostRecord originalRecord, }) async { - final restoredRecord = Map.from(originalRecord); - restoredRecord[r'$type'] = 'app.bsky.feed.post'; - final existingCreatedAt = restoredRecord['createdAt']; - if (existingCreatedAt is! String || existingCreatedAt.trim().isEmpty) { - restoredRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); - } - try { await _authRecovery.run( - (client) => - client.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord), + (client) => client.atproto.repo.createRecord( + repo: repo, + collection: collection, + rkey: rkey, + record: _postRecordJson(originalRecord), + ), ); return true; } catch (e, stackTrace) { @@ -1050,13 +1056,34 @@ class ComposeRepository { } } - Future _getRecordFromRepo({required String repo, required String collection, required String rkey}) async { + Future> _getRecordFromRepo({ + required String repo, + required String collection, + required String rkey, + }) async { final serviceHost = await _resolveRepoServiceHost(repo); return _authRecovery.run( (client) => client.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey, $service: serviceHost), ); } + FeedPostRecord? _parsePostRecord(Map value) { + if (!FeedPostRecord.validate(value)) { + return null; + } + + try { + return const FeedPostRecordConverter().fromJson(value); + } catch (error, stackTrace) { + log.w('ComposeRepository: skipped malformed feed post record', error: error, stackTrace: stackTrace); + return null; + } + } + + Map _postRecordJson(FeedPostRecord record) { + return const FeedPostRecordConverter().toJson(record); + } + Future _resolveRepoServiceHost(String repo) async { if (_isCurrentSessionRepo(repo)) { return null; diff --git a/lib/features/compose/data/draft_embed_payload.dart b/lib/features/compose/data/draft_embed_payload.dart new file mode 100644 index 0000000..0f45edc --- /dev/null +++ b/lib/features/compose/data/draft_embed_payload.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; + +part 'draft_embed_payload.freezed.dart'; + +@Freezed(fromJson: false, toJson: false) +sealed class DraftEmbedPayload with _$DraftEmbedPayload { + const DraftEmbedPayload._(); + + const factory DraftEmbedPayload.images({ + @Default([]) List paths, + @Default([]) List altTexts, + }) = DraftImagesEmbedPayload; + + const factory DraftEmbedPayload.video({required String path, @Default('') String alt}) = DraftVideoEmbedPayload; + + static DraftEmbedPayload? tryDecode(String? payload) { + if (payload == null || payload.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(payload); + if (decoded is! Map) { + return null; + } + return fromJson(Map.from(decoded)); + } catch (error) { + log.d('Failed to decode draft embed payload: $error'); + return null; + } + } + + static DraftEmbedPayload? fromJson(Map json) { + switch (json['type']) { + case 'images': + return DraftEmbedPayload.images(paths: _stringList(json['paths']), altTexts: _stringList(json['altTexts'])); + case 'video': + final path = json['path']; + if (path is! String || path.isEmpty) { + return null; + } + return DraftEmbedPayload.video(path: path, alt: json['alt'] as String? ?? ''); + default: + return null; + } + } + + static String encodeMediaPaths(Iterable paths) => jsonEncode(paths.toList(growable: false)); + + static List decodeMediaPaths(String payload) { + final decoded = jsonDecode(payload); + if (decoded is! List) { + throw FormatException('Expected draft media paths to be a JSON list.', payload); + } + return decoded.whereType().toList(growable: false); + } + + String encode() => jsonEncode(toJson()); + + Map toJson() { + return switch (this) { + DraftImagesEmbedPayload(:final paths, :final altTexts) => { + 'type': 'images', + 'paths': paths, + 'altTexts': altTexts, + }, + DraftVideoEmbedPayload(:final path, :final alt) => {'type': 'video', 'path': path, 'alt': alt}, + }; + } + + static List _stringList(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); + } +} diff --git a/lib/features/compose/data/draft_embed_payload.freezed.dart b/lib/features/compose/data/draft_embed_payload.freezed.dart new file mode 100644 index 0000000..591f537 --- /dev/null +++ b/lib/features/compose/data/draft_embed_payload.freezed.dart @@ -0,0 +1,322 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'draft_embed_payload.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DraftEmbedPayload { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DraftEmbedPayload); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'DraftEmbedPayload()'; +} + + +} + +/// @nodoc +class $DraftEmbedPayloadCopyWith<$Res> { +$DraftEmbedPayloadCopyWith(DraftEmbedPayload _, $Res Function(DraftEmbedPayload) __); +} + + +/// Adds pattern-matching-related methods to [DraftEmbedPayload]. +extension DraftEmbedPayloadPatterns on DraftEmbedPayload { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( DraftImagesEmbedPayload value)? images,TResult Function( DraftVideoEmbedPayload value)? video,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case DraftImagesEmbedPayload() when images != null: +return images(_that);case DraftVideoEmbedPayload() when video != null: +return video(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( DraftImagesEmbedPayload value) images,required TResult Function( DraftVideoEmbedPayload value) video,}){ +final _that = this; +switch (_that) { +case DraftImagesEmbedPayload(): +return images(_that);case DraftVideoEmbedPayload(): +return video(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( DraftImagesEmbedPayload value)? images,TResult? Function( DraftVideoEmbedPayload value)? video,}){ +final _that = this; +switch (_that) { +case DraftImagesEmbedPayload() when images != null: +return images(_that);case DraftVideoEmbedPayload() when video != null: +return video(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List paths, List altTexts)? images,TResult Function( String path, String alt)? video,required TResult orElse(),}) {final _that = this; +switch (_that) { +case DraftImagesEmbedPayload() when images != null: +return images(_that.paths,_that.altTexts);case DraftVideoEmbedPayload() when video != null: +return video(_that.path,_that.alt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List paths, List altTexts) images,required TResult Function( String path, String alt) video,}) {final _that = this; +switch (_that) { +case DraftImagesEmbedPayload(): +return images(_that.paths,_that.altTexts);case DraftVideoEmbedPayload(): +return video(_that.path,_that.alt);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List paths, List altTexts)? images,TResult? Function( String path, String alt)? video,}) {final _that = this; +switch (_that) { +case DraftImagesEmbedPayload() when images != null: +return images(_that.paths,_that.altTexts);case DraftVideoEmbedPayload() when video != null: +return video(_that.path,_that.alt);case _: + return null; + +} +} + +} + +/// @nodoc + + +class DraftImagesEmbedPayload extends DraftEmbedPayload { + const DraftImagesEmbedPayload({final List paths = const [], final List altTexts = const []}): _paths = paths,_altTexts = altTexts,super._(); + + + final List _paths; +@JsonKey() List get paths { + if (_paths is EqualUnmodifiableListView) return _paths; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_paths); +} + + final List _altTexts; +@JsonKey() List get altTexts { + if (_altTexts is EqualUnmodifiableListView) return _altTexts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_altTexts); +} + + +/// Create a copy of DraftEmbedPayload +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DraftImagesEmbedPayloadCopyWith get copyWith => _$DraftImagesEmbedPayloadCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DraftImagesEmbedPayload&&const DeepCollectionEquality().equals(other._paths, _paths)&&const DeepCollectionEquality().equals(other._altTexts, _altTexts)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_paths),const DeepCollectionEquality().hash(_altTexts)); + +@override +String toString() { + return 'DraftEmbedPayload.images(paths: $paths, altTexts: $altTexts)'; +} + + +} + +/// @nodoc +abstract mixin class $DraftImagesEmbedPayloadCopyWith<$Res> implements $DraftEmbedPayloadCopyWith<$Res> { + factory $DraftImagesEmbedPayloadCopyWith(DraftImagesEmbedPayload value, $Res Function(DraftImagesEmbedPayload) _then) = _$DraftImagesEmbedPayloadCopyWithImpl; +@useResult +$Res call({ + List paths, List altTexts +}); + + + + +} +/// @nodoc +class _$DraftImagesEmbedPayloadCopyWithImpl<$Res> + implements $DraftImagesEmbedPayloadCopyWith<$Res> { + _$DraftImagesEmbedPayloadCopyWithImpl(this._self, this._then); + + final DraftImagesEmbedPayload _self; + final $Res Function(DraftImagesEmbedPayload) _then; + +/// Create a copy of DraftEmbedPayload +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? paths = null,Object? altTexts = null,}) { + return _then(DraftImagesEmbedPayload( +paths: null == paths ? _self._paths : paths // ignore: cast_nullable_to_non_nullable +as List,altTexts: null == altTexts ? _self._altTexts : altTexts // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class DraftVideoEmbedPayload extends DraftEmbedPayload { + const DraftVideoEmbedPayload({required this.path, this.alt = ''}): super._(); + + + final String path; +@JsonKey() final String alt; + +/// Create a copy of DraftEmbedPayload +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DraftVideoEmbedPayloadCopyWith get copyWith => _$DraftVideoEmbedPayloadCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DraftVideoEmbedPayload&&(identical(other.path, path) || other.path == path)&&(identical(other.alt, alt) || other.alt == alt)); +} + + +@override +int get hashCode => Object.hash(runtimeType,path,alt); + +@override +String toString() { + return 'DraftEmbedPayload.video(path: $path, alt: $alt)'; +} + + +} + +/// @nodoc +abstract mixin class $DraftVideoEmbedPayloadCopyWith<$Res> implements $DraftEmbedPayloadCopyWith<$Res> { + factory $DraftVideoEmbedPayloadCopyWith(DraftVideoEmbedPayload value, $Res Function(DraftVideoEmbedPayload) _then) = _$DraftVideoEmbedPayloadCopyWithImpl; +@useResult +$Res call({ + String path, String alt +}); + + + + +} +/// @nodoc +class _$DraftVideoEmbedPayloadCopyWithImpl<$Res> + implements $DraftVideoEmbedPayloadCopyWith<$Res> { + _$DraftVideoEmbedPayloadCopyWithImpl(this._self, this._then); + + final DraftVideoEmbedPayload _self; + final $Res Function(DraftVideoEmbedPayload) _then; + +/// Create a copy of DraftEmbedPayload +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? path = null,Object? alt = null,}) { + return _then(DraftVideoEmbedPayload( +path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable +as String,alt: null == alt ? _self.alt : alt // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/devtools/cubit/dev_tools_cubit.dart b/lib/features/devtools/cubit/dev_tools_cubit.dart index 2e4d080..61cab03 100644 --- a/lib/features/devtools/cubit/dev_tools_cubit.dart +++ b/lib/features/devtools/cubit/dev_tools_cubit.dart @@ -85,7 +85,7 @@ final class AtprotoDevToolsRepository implements DevToolsRepository { final response = await _atproto.repo.listRecords( repo: repo, collection: collection, - limit: limit, + limit: limit ?? 50, cursor: cursor, reverse: reverse, $service: serviceHost, diff --git a/lib/features/feed/cubit/saved_posts_cubit.dart b/lib/features/feed/cubit/saved_posts_cubit.dart index b9c5108..afc288c 100644 --- a/lib/features/feed/cubit/saved_posts_cubit.dart +++ b/lib/features/feed/cubit/saved_posts_cubit.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'dart:convert'; import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/bookmark/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -106,7 +107,9 @@ class SavedPostsCubit extends Cubit { } } - Future toggleSave({required String postUri, required String postJson}) async { + Future toggleSave(PostView post) async { + final postUri = post.uri.toString(); + final postJson = PoptartCacheCodecs.postView.encode(post); final isCurrentlySaved = state.isSaved(postUri); try { @@ -135,14 +138,24 @@ class SavedPostsCubit extends Cubit { } } - Future savePost({required String postUri, required String postJson}) async { + Future savePost(PostView post) async { + final postUri = post.uri.toString(); if (state.isSaved(postUri)) return true; - return toggleSave(postUri: postUri, postJson: postJson); + return toggleSave(post); } Future unsavePost(String postUri) async { if (!state.isSaved(postUri)) return true; - return toggleSave(postUri: postUri, postJson: ''); + try { + await _database.unsavePost(_accountDid, postUri); + _semanticIndexer?.removePost(postUri); + await loadSavedPosts(); + return true; + } catch (error) { + log.e('Failed to unsave post', error: error); + emit(state.copyWith(error: 'Failed to unsave post')); + return false; + } } Future unsavePostById(int id) async { @@ -200,7 +213,9 @@ class SavedPostsCubit extends Cubit { emit(state.copyWith(error: null)); } - Future cloudSave({required String postUri, required String cid, required String postJson}) async { + Future cloudSave(PostView post) async { + final postUri = post.uri.toString(); + final postJson = PoptartCacheCodecs.postView.encode(post); final currentType = state.saveTypeForUri(postUri); if (currentType == 'cloud' || currentType == 'both') return true; @@ -220,7 +235,7 @@ class SavedPostsCubit extends Cubit { ); _semanticIndexer?.queueIndexPost(postUri, postJson, _accountDid, 'saved'); } - await _postActionRepository.createBookmark(uri: AtUri.parse(postUri), cid: cid); + await _postActionRepository.createBookmark(uri: post.uri, cid: post.cid); return true; } catch (error) { log.e('Failed to cloud save post', error: error); @@ -274,8 +289,14 @@ class SavedPostsCubit extends Cubit { do { final output = await _postActionRepository.getBookmarks(limit: 100, cursor: cursor); for (final bookmark in output.bookmarks) { - final postUri = bookmark.subject.uri.toString(); - final postJson = bookmark.item.isPostView ? jsonEncode(bookmark.item.postView!.toJson()) : '{}'; + if (!bookmark.item.isPostView) { + log.d('Skipping cloud bookmark without PostView payload: ${bookmark.subject.uri}'); + continue; + } + + final post = bookmark.item.postView!; + final postUri = post.uri.toString(); + final postJson = PoptartCacheCodecs.postView.encode(post); final existing = await _database.getSavedPost(_accountDid, postUri); if (existing == null) { await _database.savePost( diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 2c76bc6..d1ab712 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,16 +1,16 @@ -import 'dart:convert'; - import 'package:poptart_core/poptart_core.dart' as atcore show AtUri; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/get_author_feed.dart'; import 'package:poptart_lex/app/bsky/unspecced/defs.dart'; import 'package:flutter/foundation.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; @@ -19,7 +19,7 @@ import 'package:lazurite/features/moderation/data/moderation_service.dart'; class FeedRepository { FeedRepository({ - required dynamic bluesky, + required Bluesky bluesky, required AppDatabase database, required String accountDid, ModerationService? moderationService, @@ -31,7 +31,7 @@ class FeedRepository { int routingEpoch = 0, int Function()? routingEpochResolver, Future Function()? onUnauthorized, - dynamic Function(AuthTokens tokens)? blueskyClientFactory, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, }) : _database = database, _accountDid = accountDid, _moderationService = moderationService, @@ -44,7 +44,7 @@ class FeedRepository { _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), _routingEpoch = routingEpoch, _routingEpochResolver = routingEpochResolver { - _authRecovery = UnauthorizedRecoveryRunner( + _authRecovery = UnauthorizedRecoveryRunner( initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, @@ -54,7 +54,7 @@ class FeedRepository { ); } - late final UnauthorizedRecoveryRunner _authRecovery; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; final String _accountDid; final ModerationService? _moderationService; @@ -135,7 +135,7 @@ class FeedRepository { final posts = []; for (final entry in cachedPosts) { try { - posts.add(FeedViewPost.fromJson(jsonDecode(entry.postJson) as Map)); + posts.add(PoptartCacheCodecs.feedViewPost.decode(entry.postJson)); } catch (error, stackTrace) { log.w( 'feed.getCachedFeedPage decode failed account=$_accountDid feedKey=$feedKey postUri=${entry.postUri}', @@ -152,8 +152,7 @@ class FeedRepository { String? cursor; if (pageMeta != null) { try { - final decoded = jsonDecode(pageMeta.payload) as Map; - cursor = decoded['cursor'] as String?; + cursor = PoptartCacheCodecs.decodeFeedPageCursor(pageMeta.payload); } catch (error, stackTrace) { log.w( 'feed.getCachedFeedPage pageMeta decode failed account=$_accountDid feedKey=$feedKey', @@ -369,7 +368,7 @@ class FeedRepository { continue; } try { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + addPost(PoptartCacheCodecs.feedViewPost.decode(cached.postJson)); } catch (error, stackTrace) { log.w( 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', @@ -381,7 +380,7 @@ class FeedRepository { } else { for (final cached in existingPosts) { try { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + addPost(PoptartCacheCodecs.feedViewPost.decode(cached.postJson)); } catch (error, stackTrace) { log.w( 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', @@ -406,7 +405,7 @@ class FeedRepository { accountDid: _accountDid, feedKey: feedKey, postUri: uri, - postJson: jsonEncode(post.toJson()), + postJson: PoptartCacheCodecs.feedViewPost.encode(post), sortOrder: sortOrder, ), ); @@ -418,7 +417,7 @@ class FeedRepository { await _database.cacheFeedPage( accountDid: _accountDid, feedKey: feedKey, - payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), + payload: PoptartCacheCodecs.encodeFeedPageMetadata(cursor: result.cursor, lastRequestCursor: cursor), ); }); }); diff --git a/lib/features/feed/data/liked_posts_repository.dart b/lib/features/feed/data/liked_posts_repository.dart index 505cf91..e2cf7a9 100644 --- a/lib/features/feed/data/liked_posts_repository.dart +++ b/lib/features/feed/data/liked_posts_repository.dart @@ -1,14 +1,14 @@ -import 'dart:convert'; - import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:drift/drift.dart' show Value; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/search/data/semantic_indexer.dart'; class LikedPostsRepository { LikedPostsRepository({ - required dynamic bluesky, + required Bluesky bluesky, required AppDatabase database, SemanticIndexer? semanticIndexer, String? appViewProvider, @@ -21,7 +21,7 @@ class LikedPostsRepository { appViewProviderResolver: appViewProviderResolver, ); - final dynamic _bluesky; + final Bluesky _bluesky; final AppDatabase _database; final SemanticIndexer? _semanticIndexer; final AppViewRequestContext _appViewContext; @@ -46,7 +46,7 @@ class LikedPostsRepository { ); final data = response.data; - final posts = (data.feed as List).whereType().toList(growable: false); + final posts = data.feed; if (posts.isEmpty) break; scanned += posts.length; @@ -54,7 +54,7 @@ class LikedPostsRepository { for (final FeedViewPost feedViewPost in posts) { final postUri = feedViewPost.post.uri.toString(); final likedAt = _resolveLikedAt(feedViewPost); - final postJson = jsonEncode(feedViewPost.toJson()); + final postJson = PoptartCacheCodecs.feedViewPost.encode(feedViewPost); final existing = await _database.getLikedPost(accountDid, postUri); if (existing != null) { @@ -100,33 +100,19 @@ class LikedPostsRepository { return fromReason; } - final indexedAt = (feedViewPost.post as dynamic).indexedAt; - if (indexedAt is DateTime) { - return indexedAt.toUtc(); - } - - final createdAtRaw = feedViewPost.post.record['createdAt']; - if (createdAtRaw is String) { - final parsed = DateTime.tryParse(createdAtRaw); - if (parsed != null) { - return parsed.toUtc(); - } - } - - return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + return feedViewPost.post.indexedAt.toUtc(); } - DateTime? _extractReasonIndexedAt(dynamic reason) { + DateTime? _extractReasonIndexedAt(UFeedViewPostReason? reason) { if (reason == null) { return null; } - try { - final map = reason is Map ? reason : (reason as dynamic).toJson(); - final indexedAt = map['indexedAt'] as String?; - if (indexedAt != null) { - return DateTime.parse(indexedAt).toUtc(); - } - } catch (_) {} - return null; + + if (reason.isReasonRepost) { + return reason.reasonRepost!.indexedAt.toUtc(); + } + + final indexedAt = reason.unknown?['indexedAt']; + return indexedAt is String ? DateTime.tryParse(indexedAt)?.toUtc() : null; } } diff --git a/lib/features/feed/data/post_action_repository.dart b/lib/features/feed/data/post_action_repository.dart index 3314f16..6b7c659 100644 --- a/lib/features/feed/data/post_action_repository.dart +++ b/lib/features/feed/data/post_action_repository.dart @@ -62,7 +62,7 @@ class PostActionRepository { Future getBookmarks({int? limit, String? cursor}) async { final response = await _bluesky.bookmark.getBookmarks( - limit: limit, + limit: limit ?? 50, cursor: cursor, $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.bookmark.getBookmarks'), ); diff --git a/lib/features/feed/data/post_thread_repository.dart b/lib/features/feed/data/post_thread_repository.dart index cbd7ff9..4831628 100644 --- a/lib/features/feed/data/post_thread_repository.dart +++ b/lib/features/feed/data/post_thread_repository.dart @@ -1,12 +1,12 @@ -import 'dart:convert'; - import 'package:poptart_core/poptart_core.dart' as atcore; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/get_post_thread.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; @@ -14,14 +14,14 @@ import 'package:lazurite/features/moderation/data/moderation_service.dart'; class PostThreadRepository { PostThreadRepository({ - required dynamic bluesky, + required Bluesky bluesky, required AppDatabase database, required String accountDid, ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, Future Function()? onUnauthorized, - dynamic Function(AuthTokens tokens)? blueskyClientFactory, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, }) : _database = database, _accountDid = accountDid, _moderationService = moderationService, @@ -29,7 +29,7 @@ class PostThreadRepository { appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ) { - _authRecovery = UnauthorizedRecoveryRunner( + _authRecovery = UnauthorizedRecoveryRunner( initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, @@ -39,7 +39,7 @@ class PostThreadRepository { ); } - late final UnauthorizedRecoveryRunner _authRecovery; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; final String _accountDid; final ModerationService? _moderationService; @@ -54,7 +54,7 @@ class PostThreadRepository { final response = await _authRecovery.run( (client) => client.feed.getPostThread(uri: atcore.AtUri.parse(uri), $headers: headers), ); - final thread = response.data.thread as UFeedGetPostThreadThread; + final thread = response.data.thread; if (thread.isThreadViewPost) { final threadViewPost = thread.threadViewPost!; @@ -88,7 +88,11 @@ class PostThreadRepository { Future _cacheThread(ThreadViewPost thread) async { final rootUri = _threadRoot(thread).post.uri.toString(); - await _database.cacheThreadRoot(accountDid: _accountDid, rootUri: rootUri, payload: jsonEncode(thread.toJson())); + await _database.cacheThreadRoot( + accountDid: _accountDid, + rootUri: rootUri, + payload: PoptartCacheCodecs.threadViewPost.encode(thread), + ); await _database.pruneCachedThreadRoots(_accountDid, OfflineCachePolicy.threadRootLimit); } @@ -96,7 +100,7 @@ class PostThreadRepository { final direct = await _database.getCachedThreadRoot(_accountDid, requestedUri); if (direct != null) { try { - return ThreadViewPost.fromJson(jsonDecode(direct.payload) as Map); + return PoptartCacheCodecs.threadViewPost.decode(direct.payload); } catch (error, stackTrace) { log.d( 'thread.cache failed to decode direct snapshot for requestedUri=$requestedUri', @@ -111,7 +115,7 @@ class PostThreadRepository { )..where((row) => row.accountDid.equals(_accountDid))).get(); for (final candidate in all) { try { - final decoded = ThreadViewPost.fromJson(jsonDecode(candidate.payload) as Map); + final decoded = PoptartCacheCodecs.threadViewPost.decode(candidate.payload); if (_containsPostUri(decoded, requestedUri)) { return decoded; } diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 8535a0b..ca00352 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/post.dart'; @@ -923,7 +922,7 @@ class _FocusedPostContent extends StatelessWidget { final post = thread.post; await HapticHelper.lightImpact(); - await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); + await cubit.toggleSave(post); } void _showMoreOptions(BuildContext context) { diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index ef1eda9..92e434f 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -477,11 +477,7 @@ class _SavedPostCard extends StatelessWidget { FeedViewPost? _deserializePost() { try { - final json = jsonDecode(savedPost.postJson) as Map; - if (json.containsKey('post')) { - return FeedViewPost.fromJson(json); - } - return FeedViewPost(post: PostView.fromJson(json)); + return PoptartCacheCodecs.decodeSavedOrLikedPost(savedPost.postJson); } catch (e) { log.e('Failed to deserialize saved post', error: e); return null; @@ -559,11 +555,7 @@ class _LikedPostCard extends StatelessWidget { FeedViewPost? _deserializePost() { try { - final json = jsonDecode(likedPost.postJson) as Map; - if (json.containsKey('post')) { - return FeedViewPost.fromJson(json); - } - return FeedViewPost(post: PostView.fromJson(json)); + return PoptartCacheCodecs.decodeSavedOrLikedPost(likedPost.postJson); } catch (e) { log.e('Failed to deserialize liked post', error: e); return null; diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index f8e9901..f3d8716 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/post.dart'; @@ -298,14 +297,14 @@ class _PostCardWithActionsContent extends StatelessWidget { final cubit = context.read(); final post = feedViewPost.post; await HapticHelper.lightImpact(); - await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); + await cubit.toggleSave(post); } Future _onCloudSave(BuildContext context) async { final cubit = context.read(); final post = feedViewPost.post; await HapticHelper.lightImpact(); - await cubit.cloudSave(postUri: post.uri.toString(), cid: post.cid, postJson: jsonEncode(post.toJson())); + await cubit.cloudSave(post); } Future _onCloudUnsave(BuildContext context) async { diff --git a/lib/features/lists/bloc/list_bloc.dart b/lib/features/lists/bloc/list_bloc.dart index baa541e..3b501f0 100644 --- a/lib/features/lists/bloc/list_bloc.dart +++ b/lib/features/lists/bloc/list_bloc.dart @@ -1,4 +1,4 @@ -import 'package:poptart_core/poptart_core.dart' show AtUri, BlobRef; +import 'package:poptart_core/poptart_core.dart' show AtUri, Blob; import 'package:poptart_lex/app/bsky/graph/defs.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -135,7 +135,7 @@ class ListBloc extends Bloc { emit(state.copyWith(isMutating: true, errorMessage: null)); try { - BlobRef? avatarBlob; + Blob? avatarBlob; if (event.avatarBytes != null) { avatarBlob = await _listRepository.uploadListAvatar(bytes: event.avatarBytes!, mimeType: event.avatarMimeType); } diff --git a/lib/features/lists/cubit/my_lists_cubit.dart b/lib/features/lists/cubit/my_lists_cubit.dart index 2330d0d..6efb2f5 100644 --- a/lib/features/lists/cubit/my_lists_cubit.dart +++ b/lib/features/lists/cubit/my_lists_cubit.dart @@ -1,4 +1,4 @@ -import 'package:poptart_core/poptart_core.dart' show AtUri, BlobRef; +import 'package:poptart_core/poptart_core.dart' show AtUri, Blob; import 'package:poptart_lex/app/bsky/graph/defs.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -81,7 +81,7 @@ class MyListsCubit extends Cubit { String avatarMimeType = 'image/jpeg', }) async { try { - BlobRef? avatarBlob; + Blob? avatarBlob; if (avatarBytes != null) { avatarBlob = await _listRepository.uploadListAvatar(bytes: avatarBytes, mimeType: avatarMimeType); } diff --git a/lib/features/lists/data/list_repository.dart b/lib/features/lists/data/list_repository.dart index dc8ee87..c997a03 100644 --- a/lib/features/lists/data/list_repository.dart +++ b/lib/features/lists/data/list_repository.dart @@ -1,17 +1,18 @@ import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' show AtUri, BlobRef; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; import 'package:poptart_lex/app/bsky/graph/get_lists.dart'; import 'package:poptart_lex/app/bsky/graph/get_lists_with_membership.dart'; +import 'package:poptart_lex/app/bsky/graph/list.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; class ListRepository { ListRepository({ - required dynamic bluesky, + required Bluesky bluesky, ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, @@ -22,7 +23,7 @@ class ListRepository { appViewProviderResolver: appViewProviderResolver, ); - final dynamic _bluesky; + final Bluesky _bluesky; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; @@ -162,12 +163,12 @@ class ListRepository { ); } - Future uploadListAvatar({required List bytes, String mimeType = 'image/jpeg'}) async { + Future uploadListAvatar({required List bytes, String mimeType = 'image/jpeg'}) async { final response = await _bluesky.atproto.repo.uploadBlob( bytes: Uint8List.fromList(bytes), $headers: {'Content-Type': mimeType}, ); - return response.data.blob.ref; + return response.data.blob; } Future createList({ @@ -175,21 +176,20 @@ class ListRepository { required String name, required String purpose, String? description, - BlobRef? avatarBlob, + Blob? avatarBlob, }) async { - final record = { - r'$type': 'app.bsky.graph.list', - 'purpose': purpose, - 'name': name, - 'createdAt': DateTime.now().toUtc().toIso8601String(), - }; - if (description != null) record['description'] = description; - if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); + final record = GraphListRecord( + purpose: _listPurposeFromString(purpose), + name: name, + description: _trimOptional(description), + avatar: avatarBlob, + createdAt: DateTime.now().toUtc(), + ); final response = await _bluesky.atproto.repo.createRecord( repo: userDid, collection: 'app.bsky.graph.list', - record: record, + record: record.toJson(), ); return response.data.uri; } @@ -200,22 +200,21 @@ class ListRepository { required String name, required String purpose, String? description, - BlobRef? avatarBlob, + Blob? avatarBlob, }) async { - final record = { - r'$type': 'app.bsky.graph.list', - 'purpose': purpose, - 'name': name, - 'createdAt': DateTime.now().toUtc().toIso8601String(), - }; - if (description != null) record['description'] = description; - if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); + final record = GraphListRecord( + purpose: _listPurposeFromString(purpose), + name: name, + description: _trimOptional(description), + avatar: avatarBlob, + createdAt: DateTime.now().toUtc(), + ); await _bluesky.atproto.repo.putRecord( repo: userDid, collection: 'app.bsky.graph.list', rkey: listUri.rkey, - record: record, + record: record.toJson(), ); } @@ -274,6 +273,19 @@ class ListRepository { GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.curatelist), GraphGetListsWithMembershipPurposes.knownValue(data: KnownGraphGetListsWithMembershipPurposes.modlist), ]; + + ListPurpose _listPurposeFromString(String value) { + final purpose = ListPurpose.valueOf(value.trim()); + if (purpose == null) { + throw ArgumentError.value(value, 'purpose', 'List purpose is required.'); + } + return purpose; + } + + String? _trimOptional(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; + } } class ListsResult { diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart index 53837f8..ed1c181 100644 --- a/lib/features/moderation/data/moderation_service.dart +++ b/lib/features/moderation/data/moderation_service.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:convert'; - import 'package:poptart_lex/com/atproto/label/defs.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/actor/get_preferences.dart'; @@ -8,18 +6,19 @@ import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/labeler/defs.dart'; import 'package:poptart_lex/app/bsky/labeler/get_services.dart'; import 'package:poptart_lex/app/bsky/notification/list_notifications.dart' as notifications; -import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; const _officialBlueskyLabelerDid = 'did:plc:ar7c4by46qjdydhdevvrndac'; const _maxCustomLabelers = 20; class ModerationService { ModerationService({ - required dynamic bluesky, + required Bluesky bluesky, AppDatabase? database, String? accountDid, String? userDid, @@ -39,7 +38,7 @@ class ModerationService { ); } - final dynamic _bluesky; + final Bluesky _bluesky; final AppDatabase? _database; final String? _accountDid; final String? _userDid; @@ -452,7 +451,10 @@ class ModerationService { final detailed = view.labelerViewDetailed!; _labelerPoliciesByDid[detailed.creator.did] = detailed.policies; - await _database.upsertLabelerCache(detailed.creator.did, jsonEncode(detailed.policies.toJson())); + await _database.upsertLabelerCache( + detailed.creator.did, + PoptartCacheCodecs.labelerPolicies.encode(detailed.policies), + ); } } @@ -470,7 +472,7 @@ class ModerationService { continue; } - final policies = LabelerPolicies.fromJson(jsonDecode(cached.policiesJson) as Map); + final policies = PoptartCacheCodecs.labelerPolicies.decode(cached.policiesJson); _labelerPoliciesByDid[did] = policies; definitions[did] = _interpretedLabelDefinitionsFromPolicies(policies, labelerDid: did); } @@ -485,8 +487,7 @@ class ModerationService { return; } - final payload = jsonEncode(preferences.map((preference) => preference.toJson()).toList()); - await database.setSetting(prefsKey, payload); + await database.setSetting(prefsKey, PoptartCacheCodecs.encodeModerationPreferences(preferences)); } Future?> _loadCachedPreferences() async { @@ -501,10 +502,7 @@ class ModerationService { return null; } - final decoded = jsonDecode(payload) as List; - return decoded - .map((json) => const UPreferencesConverter().fromJson(Map.from(json as Map))) - .toList(); + return PoptartCacheCodecs.decodeModerationPreferences(payload); } Future> _getSubscribedLabelerDids() async { @@ -629,12 +627,7 @@ class ModerationService { return explicitUserDid; } - final bluesky = _bluesky; - if (bluesky is Bluesky) { - return bluesky.oAuthSession?.sub ?? bluesky.session?.did; - } - - return null; + return _bluesky.oAuthSession?.sub ?? _bluesky.session?.did; } LabelValueDefinition? _labelValueDefinitionForIdentifier(LabelerPolicies? policies, String identifier) { diff --git a/lib/features/notifications/bloc/notification_bloc.dart b/lib/features/notifications/bloc/notification_bloc.dart index c5c3eb2..b7cdb65 100644 --- a/lib/features/notifications/bloc/notification_bloc.dart +++ b/lib/features/notifications/bloc/notification_bloc.dart @@ -102,8 +102,8 @@ class NotificationBloc extends Bloc { ), ); } - } catch (_) { - log.w('Failed to mark notifications as read/seen'); + } catch (error, stackTrace) { + log.w('Failed to mark notifications as read/seen', error: error, stackTrace: stackTrace); } } } diff --git a/lib/features/profile/data/follow_audit_repository.dart b/lib/features/profile/data/follow_audit_repository.dart index abd2ab3..29fc83c 100644 --- a/lib/features/profile/data/follow_audit_repository.dart +++ b/lib/features/profile/data/follow_audit_repository.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; -import 'package:poptart_core/poptart_core.dart' show AtUri; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/follow.dart'; +import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; import 'package:equatable/equatable.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; enum FollowStatus { deleted, deactivated, suspended, blockedBy, blocking, mutualBlock, hidden, selfFollow } @@ -82,14 +83,14 @@ const _maxRetries = 3; const _unfollowBatchSize = 200; class FollowAuditRepository { - FollowAuditRepository({required dynamic bluesky, String? appViewProvider, String Function()? appViewProviderResolver}) + FollowAuditRepository({required Bluesky bluesky, String? appViewProvider, String Function()? appViewProviderResolver}) : _bluesky = bluesky, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ); - final dynamic _bluesky; + final Bluesky _bluesky; final AppViewRequestContext _appViewContext; Future fetchFollowCount(String did) async { @@ -117,15 +118,20 @@ class FollowAuditRepository { ); final records = []; - final rawRecords = response.data.records as List; - for (final raw in rawRecords) { + for (final raw in response.data.records) { + final value = raw.value; + if (!GraphFollowRecord.validate(value)) { + log.w('FollowAuditRepository: skipping malformed follow record uri=${raw.uri}'); + continue; + } + + final follow = const GraphFollowRecordConverter().fromJson(value); final uri = raw.uri.toString(); final rkey = AtUri.parse(uri).rkey; - final subjectDid = raw.value['subject'] as String; - records.add(FollowRecord(uri: uri, rkey: rkey, subjectDid: subjectDid)); + records.add(FollowRecord(uri: uri, rkey: rkey, subjectDid: follow.subject)); } - return FollowRecordPage(records: records, cursor: response.data.cursor as String?); + return FollowRecordPage(records: records, cursor: response.data.cursor); } Stream scanFollows(String did) async* { @@ -252,7 +258,7 @@ class FollowAuditRepository { $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfiles'), ); final result = {}; - for (final profile in response.data.profiles as List) { + for (final profile in response.data.profiles) { final view = _asProfileView(profile); if (view != null) { result[view.did] = view; @@ -368,7 +374,7 @@ class FollowAuditRepository { message.toLowerCase().contains('network'); } - ProfileView? _asProfileView(dynamic profile) { + ProfileView? _asProfileView(Object? profile) { if (profile is ProfileView) return profile; if (profile is ProfileViewDetailed) { return ProfileView( @@ -435,19 +441,15 @@ class FollowAuditRepository { } String? _currentSessionDid() { - try { - final sessionDid = (_bluesky.session?.did as String?)?.trim().toLowerCase(); - if (sessionDid != null && sessionDid.isNotEmpty) { - return sessionDid; - } - } catch (_) {} + final sessionDid = _bluesky.session?.did.trim().toLowerCase(); + if (sessionDid != null && sessionDid.isNotEmpty) { + return sessionDid; + } - try { - final oauthDid = (_bluesky.oAuthSession?.sub as String?)?.trim().toLowerCase(); - if (oauthDid != null && oauthDid.isNotEmpty) { - return oauthDid; - } - } catch (_) {} + final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); + if (oauthDid != null && oauthDid.isNotEmpty) { + return oauthDid; + } return null; } diff --git a/lib/features/profile/data/profile_context_repository.dart b/lib/features/profile/data/profile_context_repository.dart index 34b6615..0cb4c18 100644 --- a/lib/features/profile/data/profile_context_repository.dart +++ b/lib/features/profile/data/profile_context_repository.dart @@ -1,9 +1,11 @@ -import 'package:poptart_core/poptart_core.dart' show AtUri; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/block.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:equatable/equatable.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; const _blockedByPageSize = 16; const _listsPageSize = 16; @@ -35,15 +37,15 @@ class BlockedByEntry extends Equatable { class ProfileContextRepository { ProfileContextRepository({ - required dynamic bluesky, - dynamic publicBluesky, + required Bluesky bluesky, + Bluesky? publicBluesky, required ConstellationClient constellationClient, }) : _bluesky = bluesky, _publicBluesky = publicBluesky ?? bluesky, _constellation = constellationClient; - final dynamic _bluesky; - final dynamic _publicBluesky; + final Bluesky _bluesky; + final Bluesky _publicBluesky; final ConstellationClient _constellation; /// Returns the number of accounts that have blocked [did]. @@ -109,8 +111,8 @@ class ProfileContextRepository { cursor: cursor, ); - total += (response.data.records as List).length; - cursor = response.data.cursor as String?; + total += response.data.records.length; + cursor = response.data.cursor; } while (cursor != null); return total; @@ -129,12 +131,12 @@ class ProfileContextRepository { cursor: cursor, ); - final subjectDids = (response.data.records as List).map((r) => r.value['subject'] as String).toList(); + final subjectDids = response.data.records.map(_blockSubjectDid).whereType().toList(growable: false); final hydrated = await _hydrateProfiles(subjectDids); return ( profiles: hydrated.profiles, unavailable: hydrated.unavailable, - cursor: response.data.cursor as String?, + cursor: response.data.cursor, total: hydrated.profiles.length, ); } @@ -164,7 +166,7 @@ class ProfileContextRepository { try { final uri = AtUri.parse(uriString); final response = await _publicBluesky.graph.getList(list: uri, limit: 1); - lists.add(response.data.list as ListView); + lists.add(response.data.list); } catch (error, stackTrace) { log.w( 'skipping invalid or unavailable list in profile context: $uriString', @@ -196,7 +198,7 @@ class ProfileContextRepository { ); try { final response = await _publicBluesky.actor.getProfiles(actors: batch); - for (final profile in response.data.profiles as List) { + for (final profile in response.data.profiles) { final converted = _asProfileView(profile); if (converted != null) { resolvedProfiles[converted.did] = converted; @@ -352,19 +354,15 @@ class ProfileContextRepository { } String? _currentSessionDid() { - try { - final sessionDid = (_bluesky.session?.did as String?)?.trim().toLowerCase(); - if (sessionDid != null && sessionDid.isNotEmpty) { - return sessionDid; - } - } catch (_) {} + final sessionDid = _bluesky.session?.did.trim().toLowerCase(); + if (sessionDid != null && sessionDid.isNotEmpty) { + return sessionDid; + } - try { - final oauthDid = (_bluesky.oAuthSession?.sub as String?)?.trim().toLowerCase(); - if (oauthDid != null && oauthDid.isNotEmpty) { - return oauthDid; - } - } catch (_) {} + final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); + if (oauthDid != null && oauthDid.isNotEmpty) { + return oauthDid; + } return null; } @@ -381,7 +379,7 @@ class ProfileContextRepository { return 'Public profile lookup failed'; } - ProfileView? _asProfileView(dynamic profile) { + ProfileView? _asProfileView(Object? profile) { if (profile is ProfileView) { return profile; } @@ -408,5 +406,16 @@ class ProfileContextRepository { return null; } + String? _blockSubjectDid(RepoListRecordsRecord record) { + final value = record.value; + if (!GraphBlockRecord.validate(value)) { + return null; + } + + final block = const GraphBlockRecordConverter().fromJson(value); + final subject = block.subject.trim(); + return subject.isEmpty ? null : subject; + } + bool _isNotFound(ConstellationException error) => error.message.startsWith('HTTP 404'); } diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 778ae8e..d42c59d 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -4,8 +4,12 @@ import 'dart:typed_data'; import 'package:poptart_core/poptart_core.dart' as atp_core; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/profile.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/like.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:characters/characters.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -20,13 +24,13 @@ import 'package:lazurite/features/moderation/data/moderation_service.dart'; class ProfileRepository { ProfileRepository({ required AppDatabase database, - required dynamic bluesky, + required Bluesky bluesky, ModerationService? moderationService, ActorRepositoryServiceResolver? actorRepositoryServiceResolver, String? appViewProvider, String Function()? appViewProviderResolver, Future Function()? onUnauthorized, - dynamic Function(AuthTokens tokens)? blueskyClientFactory, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, }) : _database = database, _moderationService = moderationService, _actorRepoResolver = actorRepositoryServiceResolver ?? _createActorRepositoryServiceResolver(), @@ -34,7 +38,7 @@ class ProfileRepository { appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ) { - _authRecovery = UnauthorizedRecoveryRunner( + _authRecovery = UnauthorizedRecoveryRunner( initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, @@ -44,9 +48,9 @@ class ProfileRepository { ); } - late final UnauthorizedRecoveryRunner _authRecovery; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; - dynamic get _bluesky => _authRecovery.client; + Bluesky get _bluesky => _authRecovery.client; final ModerationService? _moderationService; final ActorRepositoryServiceResolver? _actorRepoResolver; final AppViewRequestContext _appViewContext; @@ -82,7 +86,9 @@ class ProfileRepository { final cachedProfile = await _getCachedProfile(actor); if (cachedProfile != null) { log.w('ProfileRepository: Using cached profile for $actor after request failure'); - log.w('ProfileRepository: getProfile cached JSON ${jsonEncode(cachedProfile.toJson())}'); + log.w( + 'ProfileRepository: getProfile cached JSON ${PoptartCacheCodecs.profileViewDetailed.encode(cachedProfile)}', + ); if (_moderationService?.shouldFilterProfileDetailedInView(cachedProfile) ?? false) { throw Exception('Profile hidden by moderation preferences'); } @@ -121,7 +127,9 @@ class ProfileRepository { final batch = normalizedActors.sublist(i, (i + _maxProfilesBatchSize).clamp(0, normalizedActors.length)); final response = await _authRecovery.run((client) => client.actor.getProfiles(actors: batch, $headers: headers)); profiles.addAll( - response.data.profiles.where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)), + response.data.profiles + .map(_profileViewFromDetailed) + .where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)), ); } @@ -151,12 +159,8 @@ class ProfileRepository { final response = await _authRecovery.run( (client) => client.graph.getFollows(actor: actor, cursor: cursor, limit: limit, $headers: headers), ); - final profiles = _filterProfileList(response.data.follows as List); - return ProfileConnectionsPage( - subject: response.data.subject as ProfileView, - profiles: profiles, - cursor: response.data.cursor as String?, - ); + final profiles = _filterProfileList(response.data.follows); + return ProfileConnectionsPage(subject: response.data.subject, profiles: profiles, cursor: response.data.cursor); } Future getFollowers({required String actor, String? cursor, int limit = 50}) async { @@ -167,12 +171,8 @@ class ProfileRepository { final response = await _authRecovery.run( (client) => client.graph.getFollowers(actor: actor, cursor: cursor, limit: limit, $headers: headers), ); - final profiles = _filterProfileList(response.data.followers as List); - return ProfileConnectionsPage( - subject: response.data.subject as ProfileView, - profiles: profiles, - cursor: response.data.cursor as String?, - ); + final profiles = _filterProfileList(response.data.followers); + return ProfileConnectionsPage(subject: response.data.subject, profiles: profiles, cursor: response.data.cursor); } /// Likes transport matrix: @@ -191,7 +191,7 @@ class ProfileRepository { final response = await _authRecovery.run( (client) => client.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers), ); - final feed = (response.data.feed as List).whereType().toList(growable: false); + final feed = response.data.feed; final moderationService = _moderationService; final posts = moderationService == null ? feed @@ -221,7 +221,7 @@ class ProfileRepository { $service: resolved.pdsHost, ), ); - final likeRecords = _extractLikeRecords(recordsResponse.data.records as List); + final likeRecords = _extractLikeRecords(recordsResponse.data.records); if (likeRecords.isEmpty) { return ProfileActorLikesResult(entries: const [], cursor: recordsResponse.data.cursor); } @@ -302,23 +302,21 @@ class ProfileRepository { final response = await _authRecovery.run( (client) => client.atproto.repo.getRecord(repo: did, collection: 'app.bsky.actor.profile', rkey: 'self'), ); - final currentRecord = Map.from(response.data.value as Map); - final updatedRecord = Map.from(currentRecord); - updatedRecord['\$type'] = 'app.bsky.actor.profile'; - - _setOptionalString(updatedRecord, 'displayName', draft.displayName); - _setOptionalString(updatedRecord, 'description', draft.description); - _setOptionalString(updatedRecord, 'pronouns', draft.pronouns); - _setOptionalString(updatedRecord, 'website', draft.website); + var updatedRecord = _profileRecordFromValue(response.data.value).copyWith( + displayName: _trimOptional(draft.displayName), + description: _trimOptional(draft.description), + pronouns: _trimOptional(draft.pronouns), + website: _trimOptional(draft.website), + ); final avatar = draft.avatar; if (avatar != null) { - updatedRecord['avatar'] = (await _uploadProfileBlob(avatar)).toJson(); + updatedRecord = updatedRecord.copyWith(avatar: await _uploadProfileBlob(avatar)); } final banner = draft.banner; if (banner != null) { - updatedRecord['banner'] = (await _uploadProfileBlob(banner)).toJson(); + updatedRecord = updatedRecord.copyWith(banner: await _uploadProfileBlob(banner)); } await _authRecovery.run( @@ -327,7 +325,7 @@ class ProfileRepository { collection: 'app.bsky.actor.profile', rkey: 'self', validate: true, - record: updatedRecord, + record: _profileRecordJson(updatedRecord), swapRecord: response.data.cid, ), ); @@ -340,16 +338,28 @@ class ProfileRepository { $headers: {'Content-Type': upload.mimeType}, ), ); - return response.data.blob as atp_core.Blob; + return response.data.blob; } - void _setOptionalString(Map record, String key, String? value) { + String? _trimOptional(String? value) { final trimmed = value?.trim(); if (trimmed == null || trimmed.isEmpty) { - record.remove(key); - return; + return null; + } + return trimmed; + } + + ActorProfileRecord _profileRecordFromValue(Map value) { + if (!ActorProfileRecord.validate(value)) { + log.w('ProfileRepository: current profile record missing app.bsky.actor.profile type; rebuilding typed record'); + return const ActorProfileRecord(); } - record[key] = trimmed; + + return const ActorProfileRecordConverter().fromJson(value); + } + + Map _profileRecordJson(ActorProfileRecord record) { + return const ActorProfileRecordConverter().toJson(record); } void _validateProfileEditDraft(ProfileEditDraft draft) { @@ -398,12 +408,16 @@ class ProfileRepository { } log.d('ProfileRepository: Found cached profile for $actor'); - return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map); + return PoptartCacheCodecs.profileViewDetailed.decode(cachedProfile.payload); } Future _cacheProfileSafely(ProfileViewDetailed profile) async { try { - await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); + await _database.cacheProfile( + did: profile.did, + handle: profile.handle, + payload: PoptartCacheCodecs.profileViewDetailed.encode(profile), + ); log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); } catch (error, stackTrace) { log.w( @@ -416,10 +430,6 @@ class ProfileRepository { String _describeClientContext() { final bluesky = _bluesky; - if (bluesky is! Bluesky) { - return 'unknown client'; - } - final oauthSession = bluesky.oAuthSession; final session = bluesky.session; final configuredService = bluesky.service; @@ -453,40 +463,15 @@ class ProfileRepository { return false; } - final bluesky = _bluesky; - if (bluesky is Bluesky) { - final session = bluesky.session; - final sessionDid = session?.did.trim().toLowerCase(); - final sessionHandle = session?.handle.trim().toLowerCase(); - if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { - return true; - } - - final oauthDid = bluesky.oAuthSession?.sub.trim().toLowerCase(); - return normalizedActor == oauthDid; - } - - try { - final session = bluesky.session; - final sessionDid = (session?.did as String?)?.trim().toLowerCase(); - final sessionHandle = (session?.handle as String?)?.trim().toLowerCase(); - if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { - return true; - } - } catch (e) { - log.d('ProfileRepository: Unable to parse current session actor', error: e); + final session = _bluesky.session; + final sessionDid = session?.did.trim().toLowerCase(); + final sessionHandle = session?.handle.trim().toLowerCase(); + if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { + return true; } - try { - final oauthSession = bluesky.oAuthSession; - final oauthDid = (oauthSession?.sub as String?)?.trim().toLowerCase(); - if (normalizedActor == oauthDid) { - return true; - } - } catch (e) { - log.d('ProfileRepository: Unable to parse current session actor', error: e); - } - return false; + final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); + return normalizedActor == oauthDid; } Future _resolveActorRepositoryService(String actor) async { @@ -497,39 +482,34 @@ class ProfileRepository { return resolver.resolve(actor); } - List<_LikeRecord> _extractLikeRecords(List rawRecords) { + List<_LikeRecord> _extractLikeRecords(List rawRecords) { final records = <_LikeRecord>[]; for (final raw in rawRecords) { - final value = (raw as dynamic).value; - if (value is! Map) { + final value = raw.value; + if (!FeedLikeRecord.validate(value)) { continue; } - final subject = value['subject']; - if (subject is! Map) { + final like = const FeedLikeRecordConverter().fromJson(value); + final subjectUri = like.subject.uri.toString(); + if (subjectUri.isEmpty) { continue; } - final subjectUri = subject['uri']; - if (subjectUri is String && subjectUri.isNotEmpty) { - final createdAtRaw = value['createdAt']; - final createdAt = createdAtRaw is String ? DateTime.tryParse(createdAtRaw) : null; - records.add(_LikeRecord(subjectUri: subjectUri, createdAt: createdAt)); - } + records.add(_LikeRecord(subjectUri: subjectUri, createdAt: like.createdAt)); } return records; } - DateTime? _extractLikedAtFromReason(dynamic reason) { + DateTime? _extractLikedAtFromReason(UFeedViewPostReason? reason) { if (reason == null) { return null; } - try { - final map = reason is Map ? reason : (reason as dynamic).toJson(); - final indexedAt = map['indexedAt'] as String?; - return indexedAt == null ? null : DateTime.tryParse(indexedAt); - } catch (error, stackTrace) { - log.d('ProfileRepository: ignored malformed actor likes reason', error: error, stackTrace: stackTrace); - return null; + + if (reason.isReasonRepost) { + return reason.reasonRepost!.indexedAt.toUtc(); } + + final indexedAt = reason.unknown?['indexedAt']; + return indexedAt is String ? DateTime.tryParse(indexedAt)?.toUtc() : null; } List _filterProfileList(List profiles) { @@ -537,6 +517,25 @@ class ProfileRepository { if (moderationService == null) return profiles; return profiles.where((profile) => !moderationService.shouldFilterProfileInList(profile)).toList(growable: false); } + + ProfileView _profileViewFromDetailed(ProfileViewDetailed profile) { + return ProfileView( + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + pronouns: profile.pronouns, + description: profile.description, + avatar: profile.avatar, + associated: profile.associated, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt, + viewer: profile.viewer, + labels: profile.labels, + verification: profile.verification, + status: profile.status, + debug: profile.debug, + ); + } } class ProfileConnectionsPage { diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart index e454038..e2809e3 100644 --- a/lib/features/search/data/search_repository.dart +++ b/lib/features/search/data/search_repository.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/search_posts.dart'; @@ -8,13 +7,14 @@ import 'package:poptart_lex/app/bsky/graph/defs.dart'; import 'package:flutter/foundation.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/search/data/post_search_filters.dart'; class SearchRepository { SearchRepository({ - required dynamic bluesky, + required Bluesky bluesky, ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, @@ -35,7 +35,7 @@ class SearchRepository { _routingEpoch = routingEpoch, _routingEpochResolver = routingEpochResolver; - final dynamic _bluesky; + final Bluesky _bluesky; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; final bool _crossProviderFallbackEnabled; diff --git a/lib/features/search/data/semantic_indexer.dart b/lib/features/search/data/semantic_indexer.dart index 780f1db..cb953ba 100644 --- a/lib/features/search/data/semantic_indexer.dart +++ b/lib/features/search/data/semantic_indexer.dart @@ -1,10 +1,9 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:convert'; import 'dart:math' show min; import 'dart:typed_data'; -import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/embedding/embedding_service.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -161,32 +160,13 @@ class SemanticIndexer { String _extractText(String postJson) { try { - final map = jsonDecode(postJson) as Map; - final postView = _resolvePostView(map); - if (postView == null) return ''; + final postView = PoptartCacheCodecs.decodeSavedOrLikedPostView(postJson); return _textExtractor.extract(postView); - } catch (_) { + } catch (error) { + log.d('Failed to extract semantic index text from cached post JSON: $error'); return ''; } } - /// Resolves a [PostView] from a raw JSON map. - /// - /// Handles two formats: - /// - FeedViewPost JSON (liked posts): top-level `post` key maps to PostView. - /// - PostView JSON (saved posts): parsed directly. - PostView? _resolvePostView(Map map) { - final nested = map['post']; - if (nested is Map) { - try { - return PostView.fromJson(nested); - } catch (_) {} - } - try { - return PostView.fromJson(map); - } catch (_) {} - return null; - } - List _toDoubleList(Float32List embedding) => embedding.toList(); } diff --git a/lib/features/search/presentation/semantic_search_tab.dart b/lib/features/search/presentation/semantic_search_tab.dart index e92181c..0e4a2a2 100644 --- a/lib/features/search/presentation/semantic_search_tab.dart +++ b/lib/features/search/presentation/semantic_search_tab.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'dart:convert'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; @@ -408,11 +408,7 @@ class _ResultCard extends StatelessWidget { FeedViewPost? _toFeedViewPost() { try { - final json = jsonDecode(result.postJson) as Map; - if (result.source == 'liked') { - return FeedViewPost.fromJson(json); - } - return FeedViewPost(post: PostView.fromJson(json)); + return PoptartCacheCodecs.decodeSavedOrLikedPost(result.postJson); } catch (e) { log.e('Failed to deserialize semantic search result', error: e); return null; diff --git a/lib/features/starter_packs/data/starter_pack_repository.dart b/lib/features/starter_packs/data/starter_pack_repository.dart index 93b8f27..7ba3c69 100644 --- a/lib/features/starter_packs/data/starter_pack_repository.dart +++ b/lib/features/starter_packs/data/starter_pack_repository.dart @@ -1,14 +1,15 @@ -import 'package:poptart_core/poptart_core.dart' show AtUri; import 'package:poptart_lex/app/bsky/feed/defs.dart' show GeneratorView; import 'package:poptart_lex/app/bsky/graph/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/list.dart'; import 'package:poptart_lex/app/bsky/graph/starterpack.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; class StarterPackRepository { StarterPackRepository({ - required dynamic bluesky, + required Bluesky bluesky, ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, @@ -19,7 +20,7 @@ class StarterPackRepository { appViewProviderResolver: appViewProviderResolver, ); - final dynamic _bluesky; + final Bluesky _bluesky; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; @@ -57,7 +58,7 @@ class StarterPackRepository { await _moderationService?.headersForRequest(), ), ); - return response.data.feeds as List; + return response.data.feeds; } /// Creates a starter pack using the 3-step flow: @@ -176,7 +177,7 @@ class StarterPackRepository { } } - cursor = response.data.cursor as String?; + cursor = response.data.cursor; } while (cursor != null); return count; @@ -186,12 +187,11 @@ class StarterPackRepository { final response = await _bluesky.atproto.repo.createRecord( repo: userDid, collection: 'app.bsky.graph.list', - record: { - r'$type': 'app.bsky.graph.list', - 'purpose': 'app.bsky.graph.defs#referencelist', - 'name': 'Starter Pack Members', - 'createdAt': DateTime.now().toUtc().toIso8601String(), - }, + record: GraphListRecord( + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), + name: 'Starter Pack Members', + createdAt: DateTime.now().toUtc(), + ).toJson(), ); return response.data.uri; diff --git a/lib/features/typeahead/data/typeahead_repository.dart b/lib/features/typeahead/data/typeahead_repository.dart index 8e8f052..1736643 100644 --- a/lib/features/typeahead/data/typeahead_repository.dart +++ b/lib/features/typeahead/data/typeahead_repository.dart @@ -1,16 +1,17 @@ import 'dart:convert'; -import 'dart:io'; +import 'dart:io' as io; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:http/http.dart' as http; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; class TypeaheadRepository { TypeaheadRepository({ - dynamic bluesky, + Bluesky? bluesky, String? provider, String Function()? providerResolver, String? appViewProvider, @@ -42,7 +43,7 @@ class TypeaheadRepository { static const String _communityPath = '/xrpc/app.bsky.actor.searchActorsTypeahead'; static const String _searchActorsTypeaheadEndpoint = 'app.bsky.actor.searchActorsTypeahead'; - final dynamic _bluesky; + final Bluesky? _bluesky; final String? _provider; final String Function()? _providerResolver; final ModerationService? _moderationService; @@ -132,7 +133,7 @@ class TypeaheadRepository { }); final response = await _httpClient.get(uri, headers: headers); if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException('Bluesky typeahead request failed: HTTP ${response.statusCode}', uri: uri); + throw io.HttpException('Bluesky typeahead request failed: HTTP ${response.statusCode}', uri: uri); } final decoded = jsonDecode(response.body); @@ -166,7 +167,7 @@ class TypeaheadRepository { final response = await _httpClient.get(uri, headers: const {'X-Client': 'lazurite'}); if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException('Community typeahead request failed: HTTP ${response.statusCode}', uri: uri); + throw io.HttpException('Community typeahead request failed: HTTP ${response.statusCode}', uri: uri); } final decoded = jsonDecode(response.body); diff --git a/lib/shared/utils/atproto_datetime.dart b/lib/shared/utils/atproto_datetime.dart new file mode 100644 index 0000000..c575326 --- /dev/null +++ b/lib/shared/utils/atproto_datetime.dart @@ -0,0 +1,22 @@ +String formatAtProtoDateTime(DateTime value) { + final utc = value.toUtc(); + final year = utc.year.toString().padLeft(4, '0'); + final month = utc.month.toString().padLeft(2, '0'); + final day = utc.day.toString().padLeft(2, '0'); + final hour = utc.hour.toString().padLeft(2, '0'); + final minute = utc.minute.toString().padLeft(2, '0'); + final second = utc.second.toString().padLeft(2, '0'); + final millisecond = utc.millisecond.toString().padLeft(3, '0'); + return '$year-$month-${day}T$hour:$minute:$second.${millisecond}Z'; +} + +String? formatAtProtoDateTimeString(String value) { + final parsed = DateTime.tryParse(value); + if (parsed == null) return null; + return formatAtProtoDateTime(parsed); +} + +DateTime canonicalAtProtoDateTime(DateTime value) { + final utc = value.toUtc(); + return DateTime.utc(utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second, utc.millisecond); +} diff --git a/test/core/cache/poptart_cache_codecs_test.dart b/test/core/cache/poptart_cache_codecs_test.dart new file mode 100644 index 0000000..f025c9d --- /dev/null +++ b/test/core/cache/poptart_cache_codecs_test.dart @@ -0,0 +1,64 @@ +import 'package:poptart_core/poptart_core.dart'; +import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; + +void main() { + group('PoptartCacheCodecs', () { + test('round-trips feed view posts through cache strings', () { + final post = _feedViewPost(); + + final encoded = PoptartCacheCodecs.feedViewPost.encode(post); + final decoded = PoptartCacheCodecs.feedViewPost.decode(encoded); + + expect(decoded.post.uri.toString(), post.post.uri.toString()); + expect(decoded.post.cid, post.post.cid); + expect(decoded.post.author.handle, post.post.author.handle); + }); + + test('decodes saved post JSON and liked feed JSON as FeedViewPost', () { + final post = _feedViewPost(); + final postViewJson = PoptartCacheCodecs.postView.encode(post.post); + final feedViewPostJson = PoptartCacheCodecs.feedViewPost.encode(post); + + final decodedSaved = PoptartCacheCodecs.decodeSavedOrLikedPost(postViewJson); + final decodedLiked = PoptartCacheCodecs.decodeSavedOrLikedPost(feedViewPostJson); + + expect(decodedSaved.post.uri.toString(), post.post.uri.toString()); + expect(decodedLiked.post.uri.toString(), post.post.uri.toString()); + }); + + test('round-trips moderation preferences as a JSON string list', () { + final preferences = [const UPreferences.adultContentPref(data: AdultContentPref(enabled: true))]; + + final encoded = PoptartCacheCodecs.encodeModerationPreferences(preferences); + final decoded = PoptartCacheCodecs.decodeModerationPreferences(encoded); + + expect(decoded.single.isAdultContentPref, isTrue); + expect(decoded.single.adultContentPref!.enabled, isTrue); + }); + + test('round-trips feed page cursor metadata without exposing raw maps', () { + final encoded = PoptartCacheCodecs.encodeFeedPageMetadata(cursor: 'next', lastRequestCursor: 'previous'); + + expect(PoptartCacheCodecs.decodeFeedPageCursor(encoded), 'next'); + }); + }); +} + +FeedViewPost _feedViewPost() { + return FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'Hello typed cache', + 'createdAt': DateTime.utc(2026, 5, 12).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 5, 12), + ), + ); +} diff --git a/test/core/network/poptart_client_adapter_test.dart b/test/core/network/poptart_client_adapter_test.dart index db6e8c4..615bc12 100644 --- a/test/core/network/poptart_client_adapter_test.dart +++ b/test/core/network/poptart_client_adapter_test.dart @@ -6,6 +6,8 @@ import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:poptart_lex/app/bsky/actor.dart' as actor_methods; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed.dart' as feed_methods; +import 'package:poptart_lex/app/bsky/feed/post.dart'; +import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; void main() { @@ -45,6 +47,163 @@ void main() { }); }); + test('feed.like.create canonicalizes local datetimes to UTC milliseconds', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response( + '{"uri":"at://did:plc:test/app.bsky.feed.like/like1","cid":"like-cid"}', + 200, + request: http.Request('POST', url), + ); + }, + ); + + await bluesky.feed.like.create( + subject: RepoStrongRef(cid: 'post-cid', uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/post1')), + createdAt: DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal(), + ); + + final body = jsonDecode(capturedBody! as String) as Map; + final record = body['record'] as Map; + expect(record['createdAt'], '2026-05-12T10:11:50.052Z'); + }); + + test('repo.createRecord canonicalizes datetime strings in record maps', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response( + '{"uri":"at://did:plc:test/app.bsky.feed.like/like1","cid":"like-cid"}', + 200, + request: http.Request('POST', url), + ); + }, + ); + + await bluesky.atproto.repo.createRecord( + repo: 'did:plc:test', + collection: 'app.bsky.feed.like', + record: { + r'$type': 'app.bsky.feed.like', + 'createdAt': '2026-05-12T10:11:50.052513Z', + 'subject': { + r'$type': 'com.atproto.repo.strongRef', + 'uri': 'at://did:plc:author/app.bsky.feed.post/post1', + 'cid': 'post-cid', + }, + }, + ); + + final body = jsonDecode(capturedBody! as String) as Map; + final record = body['record'] as Map; + expect(record['createdAt'], '2026-05-12T10:11:50.052Z'); + }); + + test('notification.updateSeen canonicalizes generated input datetime values', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response('{}', 200, request: http.Request('POST', url)); + }, + ); + + await bluesky.notification.updateSeen(seenAt: DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal()); + + final body = jsonDecode(capturedBody! as String) as Map; + expect(body['seenAt'], '2026-05-12T10:11:50.052Z'); + }); + + test('notification.listNotifications canonicalizes seenAt query parameter', () async { + Uri? capturedUrl; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + getClient: (url, {headers}) async { + capturedUrl = url; + return http.Response('{"notifications":[]}', 200, request: http.Request('GET', url)); + }, + ); + + await bluesky.notification.listNotifications(seenAt: DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal()); + + expect(capturedUrl!.queryParameters['seenAt'], '2026-05-12T10:11:50.052Z'); + }); + + test('actor.putPreferences canonicalizes datetime values inside preferences', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response('{}', 200, request: http.Request('POST', url)); + }, + ); + + await bluesky.actor.putPreferences( + preferences: [ + UPreferences.mutedWordsPref( + data: MutedWordsPref( + items: [ + MutedWord( + value: 'spoiler', + targets: const [MutedWordTarget.knownValue(data: KnownMutedWordTarget.content)], + expiresAt: DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal(), + ), + ], + ), + ), + ], + ); + + final body = jsonDecode(capturedBody! as String) as Map; + final preferences = body['preferences'] as List; + final preference = preferences.single as Map; + final items = preference['items'] as List; + final mutedWord = items.single as Map; + expect(mutedWord['expiresAt'], '2026-05-12T10:11:50.052Z'); + }); + + test('repo.applyWrites canonicalizes datetime fields inside write values', () async { + Object? capturedBody; + final bluesky = Bluesky.fromSession( + const Session(did: 'did:plc:test', handle: 'test.bsky.social', accessJwt: 'access', refreshJwt: 'refresh'), + service: 'example.com', + postClient: (url, {headers, body, encoding}) async { + capturedBody = body; + return http.Response('{}', 200, request: http.Request('POST', url)); + }, + ); + + await bluesky.atproto.repo.applyWrites( + repo: 'did:plc:test', + writes: [ + const URepoApplyWritesWrites.create( + data: Create( + collection: 'app.bsky.feed.post', + value: {r'$type': 'app.bsky.feed.post', 'text': 'Hello', 'createdAt': '2026-05-12T10:11:50.052513Z'}, + ), + ), + ], + ); + + final body = jsonDecode(capturedBody! as String) as Map; + final writes = body['writes'] as List; + final write = writes.single as Map; + final value = write['value'] as Map; + expect(value['createdAt'], '2026-05-12T10:11:50.052Z'); + }); + test('actor.putPreferences encodes procedure values through descriptor type conversion', () async { Object? capturedBody; final bluesky = Bluesky.fromSession( @@ -106,7 +265,7 @@ void main() { await bluesky.feed.post.put( rkey: 'post1', - record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello', 'createdAt': DateTime.utc(2026, 5, 10, 15, 8, 56)}, + record: FeedPostRecord(text: 'Hello', createdAt: DateTime.utc(2026, 5, 10, 15, 8, 56)), ); await bluesky.feed.post.delete(rkey: 'post1'); @@ -209,8 +368,8 @@ void main() { }, ); - final response = await (bluesky as dynamic).call( - feed_methods.appBskyFeedGetFeedGenerators, + final response = await bluesky.call( + feed_methods.appBskyFeedGetFeedGenerators as XRPCMethod, parameters: { 'feeds': [AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news')], }, @@ -231,7 +390,10 @@ void main() { }, ); - final response = await (bluesky as dynamic).call(actor_methods.appBskyActorGetPreferences, parameters: {}); + final response = await bluesky.call( + actor_methods.appBskyActorGetPreferences as XRPCMethod, + parameters: {}, + ); expect(response.data.preferences, isEmpty); expect(capturedUrl!.query, isEmpty); @@ -255,8 +417,8 @@ void main() { pinned: true, ); - await (bluesky as dynamic).call( - actor_methods.appBskyActorPutPreferences, + await bluesky.call( + actor_methods.appBskyActorPutPreferences as XRPCMethod, input: { 'preferences': [ const UPreferences.savedFeedsPrefV2(data: SavedFeedsPrefV2(items: [feed])), diff --git a/test/features/compose/bloc/compose_bloc_test.dart b/test/features/compose/bloc/compose_bloc_test.dart index 2c25691..e4d2b3f 100644 --- a/test/features/compose/bloc/compose_bloc_test.dart +++ b/test/features/compose/bloc/compose_bloc_test.dart @@ -1,11 +1,14 @@ import 'dart:io'; -import 'package:poptart_core/poptart_core.dart' show Blob, BlobRef; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart' show Blob, BlobRef; +import 'package:poptart_lex/app/bsky/embed/external.dart'; +import 'package:poptart_lex/app/bsky/embed/record_with_media.dart'; +import 'package:poptart_lex/app/bsky/feed/post.dart'; class MockAppDatabase extends Mock implements AppDatabase {} @@ -687,14 +690,15 @@ void main() { 'builds Bluesky external embed from detected link preview metadata', build: () { when(() => mockRepository.buildExternalEmbedFromLink('https://example.com/article')).thenAnswer( - (_) async => { - r'$type': 'app.bsky.embed.external', - 'external': { - 'uri': 'https://example.com/article', - 'title': 'Example Article', - 'description': 'Example description', - }, - }, + (_) async => const UFeedPostEmbed.embedExternal( + data: EmbedExternal( + external: EmbedExternalExternal( + uri: 'https://example.com/article', + title: 'Example Article', + description: 'Example description', + ), + ), + ), ); when( () => mockRepository.createPost( @@ -721,14 +725,14 @@ void main() { repo: any(named: 'repo'), ), ).captured.single - as Map?; + as UFeedPostEmbed?; expect(embed, isNotNull); - expect(embed![r'$type'], 'app.bsky.embed.external'); - final external = embed['external'] as Map; - expect(external['uri'], 'https://example.com/article'); - expect(external['title'], 'Example Article'); - expect(external['description'], 'Example description'); + expect(embed!.isEmbedExternal, isTrue); + final external = embed.embedExternal!.external; + expect(external.uri, 'https://example.com/article'); + expect(external.title, 'Example Article'); + expect(external.description, 'Example description'); }, ); @@ -815,20 +819,18 @@ void main() { repo: any(named: 'repo'), ), ).captured.single - as Map; + as UFeedPostEmbed; - expect(embed[r'$type'], 'app.bsky.embed.images'); - final images = embed['images'] as List; + expect(embed.isEmbedImages, isTrue); + final images = embed.embedImages!.images; expect(images, hasLength(1)); - final image = images.single as Map; - expect(image['alt'], 'A photo'); - expect(image['aspectRatio'], {'width': 640, 'height': 480}); - expect(image['image'], { - r'$type': 'blob', - 'ref': {r'$link': 'bafkreiimageblob'}, - 'mimeType': 'image/jpeg', - 'size': 12, - }); + final image = images.single; + expect(image.alt, 'A photo'); + expect(image.aspectRatio?.width, 640); + expect(image.aspectRatio?.height, 480); + expect(image.image.ref.link, 'bafkreiimageblob'); + expect(image.image.mimeType, 'image/jpeg'); + expect(image.image.size, 12); }, ); @@ -836,14 +838,15 @@ void main() { 'builds recordWithMedia when quoting and link preview embed both exist', build: () { when(() => mockRepository.buildExternalEmbedFromLink('https://example.com/article')).thenAnswer( - (_) async => { - r'$type': 'app.bsky.embed.external', - 'external': { - 'uri': 'https://example.com/article', - 'title': 'Example Article', - 'description': 'Example description', - }, - }, + (_) async => const UFeedPostEmbed.embedExternal( + data: EmbedExternal( + external: EmbedExternalExternal( + uri: 'https://example.com/article', + title: 'Example Article', + description: 'Example description', + ), + ), + ), ); when( () => mockRepository.createPost( @@ -875,14 +878,13 @@ void main() { repo: any(named: 'repo'), ), ).captured.single - as Map?; + as UFeedPostEmbed?; expect(embed, isNotNull); - expect(embed![r'$type'], 'app.bsky.embed.recordWithMedia'); - final record = embed['record'] as Map; - expect(record[r'$type'], 'app.bsky.embed.record'); - final media = embed['media'] as Map; - expect(media[r'$type'], 'app.bsky.embed.external'); + expect(embed!.isEmbedRecordWithMedia, isTrue); + final recordWithMedia = embed.embedRecordWithMedia!; + expect(recordWithMedia.record.record.uri.toString(), 'at://did:plc:test/app.bsky.feed.post/quote'); + expect(recordWithMedia.media.isEmbedExternal, isTrue); }, ); @@ -935,14 +937,12 @@ void main() { repo: any(named: 'repo'), ), ).captured.single - as Map?; + as ReplyRef?; expect(reply, isNotNull); - final parent = reply!['parent'] as Map; - final root = reply['root'] as Map; - expect(parent['cid'], 'cid-parent-latest'); - expect(root['uri'], 'at://did:plc:test/app.bsky.feed.post/root-latest'); - expect(root['cid'], 'cid-root-latest'); + expect(reply!.parent.cid, 'cid-parent-latest'); + expect(reply.root.uri.toString(), 'at://did:plc:test/app.bsky.feed.post/root-latest'); + expect(reply.root.cid, 'cid-root-latest'); }, ); @@ -1179,7 +1179,7 @@ void main() { repo: any(named: 'repo'), ), ).captured.single - as List>; + as List; expect(facets, isEmpty); }, ); diff --git a/test/features/compose/data/compose_repository_auth_recovery_test.dart b/test/features/compose/data/compose_repository_auth_recovery_test.dart index ceeda2f..1cfb03b 100644 --- a/test/features/compose/data/compose_repository_auth_recovery_test.dart +++ b/test/features/compose/data/compose_repository_auth_recovery_test.dart @@ -1,18 +1,22 @@ +import 'dart:convert'; import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' - show Blob, BlobRef, HttpMethod, HttpStatus, RateLimit, UnauthorizedException, XRPCError, XRPCRequest, XRPCResponse; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; +import 'package:poptart_core/poptart_core.dart' show Blob, BlobRef; +import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; + +import '../../../helpers/test_bluesky_client.dart'; void main() { group('ComposeRepository auth recovery', () { test('refreshes and retries blob uploads after unauthorized response', () async { - final initialClient = _FakeBlueskyClient( - atproto: _FakeAtprotoService(repo: _FakeRepoService(throwUnauthorizedOnce: true)), - ); - final refreshedClient = _FakeBlueskyClient(atproto: _FakeAtprotoService(repo: _FakeRepoService())); + final initialTransport = _UploadBlobTransport(throwUnauthorizedOnce: true); + final refreshedTransport = _UploadBlobTransport(); + final initialClient = testBluesky(postClient: initialTransport.post); + final refreshedClient = testBluesky(postClient: refreshedTransport.post); var recoveryCalls = 0; final repository = ComposeRepository( @@ -32,83 +36,117 @@ void main() { final blob = await repository.uploadBlobRecord([1, 2, 3], mimeType: 'image/png'); expect(recoveryCalls, 1); - expect(initialClient.atproto.repo.uploadAttempts, 1); - expect(refreshedClient.atproto.repo.uploadAttempts, 1); + expect(initialTransport.uploadAttempts, 1); + expect(refreshedTransport.uploadAttempts, 1); expect(blob, isNotNull); expect(blob!.mimeType, 'image/png'); expect(blob.size, 3); - expect(refreshedClient.atproto.repo.lastUploadedBytes, [1, 2, 3]); - expect(refreshedClient.atproto.repo.lastUploadHeaders, {'Content-Type': 'image/png'}); + expect(refreshedTransport.lastUploadedBytes, [1, 2, 3]); + expect(refreshedTransport.lastUploadHeaders, containsPair('Content-Type', 'image/png')); }); - }); -} - -class _FakeBlueskyClient { - const _FakeBlueskyClient({required this.atproto}); - final _FakeAtprotoService atproto; -} + test('editPost preserves unknown top-level post record fields', () async { + final transport = _EditPostTransport( + initialRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + 'futurePostField': {'enabled': true}, + }, + ); + final repository = ComposeRepository( + bluesky: testBluesky(getClient: transport.get, postClient: transport.post), + ); -class _FakeAtprotoService { - const _FakeAtprotoService({required this.repo}); + final result = await repository.editPost( + postUri: _EditPostTransport.postUri, + currentCid: 'cid-current', + originalRecord: transport.initialRecord, + text: 'Updated text', + facets: const [], + repo: 'did:plc:test', + ); - final _FakeRepoService repo; + expect(result.isSuccess, isTrue); + expect(transport.createdRecords, hasLength(1)); + final record = transport.createdRecords.single['record'] as Map; + expect(record['text'], 'Updated text'); + expect(record['futurePostField'], {'enabled': true}); + expect(record.containsKey(r'$unknown'), isFalse); + }); + }); } -class _FakeRepoService { - _FakeRepoService({this.throwUnauthorizedOnce = false}); +class _UploadBlobTransport { + _UploadBlobTransport({this.throwUnauthorizedOnce = false}); final bool throwUnauthorizedOnce; int uploadAttempts = 0; List? lastUploadedBytes; Map? lastUploadHeaders; - Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ - required Uint8List bytes, - Map? $headers, - }) async { + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + if (url.pathSegments.last != 'com.atproto.repo.uploadBlob') { + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + uploadAttempts += 1; if (throwUnauthorizedOnce && uploadAttempts == 1) { - throw _unauthorizedException(); + return jsonResponse(url, 'POST', const { + 'error': 'Unauthorized', + 'message': '"exp" claim timestamp check failed', + }, statusCode: 401); } + final bytes = Uint8List.fromList((body as List?) ?? const []); lastUploadedBytes = bytes.toList(); - lastUploadHeaders = $headers; - return _FakeResponse( - _FakeUploadBlobData( - Blob( - mimeType: $headers?['Content-Type'] ?? 'image/jpeg', + lastUploadHeaders = headers; + return jsonResponse( + url, + 'POST', + RepoUploadBlobOutput( + blob: Blob( + mimeType: headers?['Content-Type'] ?? 'image/jpeg', size: bytes.length, ref: const BlobRef(link: 'bafkreirefreshed'), ), - ), + ).toJson(), ); } } -class _FakeResponse { - const _FakeResponse(this.data); +class _EditPostTransport { + _EditPostTransport({required Map initialRecord}) : initialRecord = Map.of(initialRecord) { + _currentRecord = Map.of(initialRecord); + } - final T data; -} + static const postUri = 'at://did:plc:test/app.bsky.feed.post/abc123'; -class _FakeUploadBlobData { - const _FakeUploadBlobData(this.blob); + final Map initialRecord; + final createdRecords = >[]; + late Map _currentRecord; + String _currentCid = 'cid-current'; - final Blob blob; -} + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'com.atproto.repo.getRecord') { + return unexpectedGetClient(url, headers: headers); + } + + return jsonResponse(url, 'GET', {'uri': postUri, 'cid': _currentCid, 'value': _currentRecord}); + } -UnauthorizedException _unauthorizedException() { - return UnauthorizedException( - XRPCResponse( - headers: const {}, - status: HttpStatus.unauthorized, - request: XRPCRequest( - method: HttpMethod.post, - url: Uri.parse('https://example.com/xrpc/com.atproto.repo.uploadBlob'), - ), - rateLimit: RateLimit.unlimited(), - data: const XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), - ), - ); + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + switch (url.pathSegments.last) { + case 'com.atproto.repo.deleteRecord': + return jsonResponse(url, 'POST', const {}); + case 'com.atproto.repo.createRecord': + final decoded = (jsonDecode(body! as String) as Map).cast(); + createdRecords.add(decoded); + _currentRecord = Map.from(decoded['record'] as Map); + _currentCid = 'cid-new'; + return jsonResponse(url, 'POST', const {'uri': postUri, 'cid': 'cid-new'}); + default: + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + } } diff --git a/test/features/compose/data/draft_embed_payload_test.dart b/test/features/compose/data/draft_embed_payload_test.dart new file mode 100644 index 0000000..d4f59f5 --- /dev/null +++ b/test/features/compose/data/draft_embed_payload_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/compose/data/draft_embed_payload.dart'; + +void main() { + group('DraftEmbedPayload', () { + test('round-trips image drafts using the existing wire shape', () { + const payload = DraftEmbedPayload.images(paths: ['/tmp/a.png'], altTexts: ['alt text']); + + final decoded = DraftEmbedPayload.tryDecode(payload.encode()); + + expect(decoded, payload); + expect(decoded!.toJson(), { + 'type': 'images', + 'paths': ['/tmp/a.png'], + 'altTexts': ['alt text'], + }); + }); + + test('round-trips video drafts using the existing wire shape', () { + const payload = DraftEmbedPayload.video(path: '/tmp/video.mp4', alt: 'caption'); + + final decoded = DraftEmbedPayload.tryDecode(payload.encode()); + + expect(decoded, payload); + expect(decoded!.toJson(), {'type': 'video', 'path': '/tmp/video.mp4', 'alt': 'caption'}); + }); + + test('preserves legacy mediaPaths JSON list support', () { + final encoded = DraftEmbedPayload.encodeMediaPaths(['/tmp/a.png', '/tmp/b.png']); + + expect(DraftEmbedPayload.decodeMediaPaths(encoded), ['/tmp/a.png', '/tmp/b.png']); + }); + + test('returns null for malformed embed payloads', () { + expect(DraftEmbedPayload.tryDecode('{'), isNull); + expect(DraftEmbedPayload.tryDecode('[]'), isNull); + expect(DraftEmbedPayload.tryDecode('{"type":"video"}'), isNull); + }); + }); +} diff --git a/test/features/feed/cubit/saved_posts_cubit_test.dart b/test/features/feed/cubit/saved_posts_cubit_test.dart index 75001d5..52461af 100644 --- a/test/features/feed/cubit/saved_posts_cubit_test.dart +++ b/test/features/feed/cubit/saved_posts_cubit_test.dart @@ -1,10 +1,13 @@ import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; import 'package:poptart_core/poptart_core.dart'; +import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/bookmark/defs.dart'; import 'package:poptart_lex/app/bsky/bookmark/get_bookmarks.dart'; +import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -27,8 +30,10 @@ void main() { const testAccountDid = 'did:plc:testuser123'; const testPostUri1 = 'at://did:plc:author1/app.bsky.feed.post/abc123'; const testPostUri2 = 'at://did:plc:author2/app.bsky.feed.post/def456'; - const testPostJson1 = '{"uri": "$testPostUri1", "text": "Post 1"}'; - const testPostJson2 = '{"uri": "$testPostUri2", "text": "Post 2"}'; + final testPost1 = _postView(testPostUri1, cid: 'cid1', text: 'Post 1'); + final testPost2 = _postView(testPostUri2, cid: 'cid2', text: 'Post 2'); + final testPostJson1 = PoptartCacheCodecs.postView.encode(testPost1); + final testPostJson2 = PoptartCacheCodecs.postView.encode(testPost2); setUp(() async { database = AppDatabase(executor: NativeDatabase.memory()); @@ -81,7 +86,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -108,7 +113,7 @@ void main() { postActionRepository: mockRepository, ); - await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.toggleSave(testPost1); expect(cubit.state.savedPosts.length, 1); expect(cubit.state.isSaved(testPostUri1), isTrue); @@ -119,7 +124,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -133,7 +138,7 @@ void main() { await cubit.loadSavedPosts(); expect(cubit.state.savedPosts.length, 1); - await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.toggleSave(testPost1); expect(cubit.state.savedPosts.length, 0); expect(cubit.state.isSaved(testPostUri1), isFalse); @@ -148,7 +153,7 @@ void main() { postActionRepository: mockRepository, ); - final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); + final result = await cubit.savePost(testPost1); expect(result, isTrue); expect(cubit.state.savedPosts.length, 1); @@ -159,7 +164,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -171,7 +176,7 @@ void main() { ); await cubit.loadSavedPosts(); - final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); + final result = await cubit.savePost(testPost1); expect(result, isTrue); }); @@ -183,7 +188,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -209,7 +214,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -235,7 +240,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -243,7 +248,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri2), - postJson: const Value(testPostJson2), + postJson: Value(testPostJson2), savedAt: Value(DateTime.now()), ), ); @@ -272,7 +277,7 @@ void main() { postActionRepository: mockRepository, ); - await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); + await cubit.savePost(testPost1); expect(cubit.state.isSaved(testPostUri1), isTrue); expect(cubit.state.isSaved(testPostUri2), isFalse); @@ -350,7 +355,7 @@ void main() { postActionRepository: mockRepository, ); - await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.toggleSave(testPost1); final posts = await database.getSavedPosts(testAccountDid); expect(posts.length, 1); @@ -364,7 +369,7 @@ void main() { postActionRepository: mockRepository, ); - await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.toggleSave(testPost1); await cubit.loadSavedPosts(); expect(cubit.state.saveTypeForUri(testPostUri1), equals('local')); @@ -386,7 +391,7 @@ void main() { postActionRepository: mockRepository, ); - final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + final result = await cubit.cloudSave(testPost1); expect(result, isTrue); final posts = await database.getSavedPosts(testAccountDid); @@ -406,10 +411,10 @@ void main() { accountDid: testAccountDid, postActionRepository: mockRepository, ); - await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.toggleSave(testPost1); await cubit.loadSavedPosts(); - final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + final result = await cubit.cloudSave(testPost1); expect(result, isTrue); final posts = await database.getSavedPosts(testAccountDid); @@ -426,14 +431,14 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('cloud'), savedAt: Value(DateTime.now()), ), ); await cubit.loadSavedPosts(); - final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + final result = await cubit.cloudSave(testPost1); expect(result, isTrue); verifyNever( @@ -457,7 +462,7 @@ void main() { postActionRepository: mockRepository, ); - final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + final result = await cubit.cloudSave(testPost1); expect(result, isFalse); expect(cubit.state.error, isNotNull); @@ -478,7 +483,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('cloud'), savedAt: Value(DateTime.now()), ), @@ -503,7 +508,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('both'), savedAt: Value(DateTime.now()), ), @@ -528,7 +533,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('both'), savedAt: Value(DateTime.now()), ), @@ -557,7 +562,7 @@ void main() { bookmarks: [ BookmarkView( subject: RepoStrongRef(uri: testUri, cid: 'cid1'), - item: const UBookmarkViewItem.unknown(data: {}), + item: UBookmarkViewItem.postView(data: testPost1), ), ], ), @@ -581,7 +586,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('local'), savedAt: Value(DateTime.now()), ), @@ -597,7 +602,7 @@ void main() { bookmarks: [ BookmarkView( subject: RepoStrongRef(uri: testUri, cid: 'cid1'), - item: const UBookmarkViewItem.unknown(data: {}), + item: UBookmarkViewItem.postView(data: testPost1), ), ], ), @@ -640,7 +645,7 @@ void main() { semanticIndexer: mockIndexer, ); - await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); + await cubit.savePost(testPost1); verify(() => mockIndexer.queueIndexPost(testPostUri1, testPostJson1, testAccountDid, 'saved')).called(1); }); @@ -650,7 +655,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -672,7 +677,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), savedAt: Value(DateTime.now()), ), ); @@ -704,7 +709,7 @@ void main() { semanticIndexer: mockIndexer, ); - await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + await cubit.cloudSave(testPost1); verify(() => mockIndexer.queueIndexPost(testPostUri1, testPostJson1, testAccountDid, 'saved')).called(1); }); @@ -715,7 +720,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('cloud'), savedAt: Value(DateTime.now()), ), @@ -739,7 +744,7 @@ void main() { SavedPostsCompanion( accountDid: const Value(testAccountDid), postUri: const Value(testPostUri1), - postJson: const Value(testPostJson1), + postJson: Value(testPostJson1), saveType: const Value('both'), savedAt: Value(DateTime.now()), ), @@ -769,7 +774,7 @@ void main() { bookmarks: [ BookmarkView( subject: RepoStrongRef(uri: testUri, cid: 'cid1'), - item: const UBookmarkViewItem.unknown(data: {}), + item: UBookmarkViewItem.postView(data: testPost1), ), ], ), @@ -788,3 +793,13 @@ void main() { }); }); } + +PostView _postView(String uri, {required String cid, required String text}) { + return PostView( + uri: AtUri.parse(uri), + cid: cid, + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 5, 12).toIso8601String()}, + indexedAt: DateTime.utc(2026, 5, 12), + ); +} diff --git a/test/features/feed/data/feed_repository_cache_test.dart b/test/features/feed/data/feed_repository_cache_test.dart index 50de1fe..2ba46d9 100644 --- a/test/features/feed/data/feed_repository_cache_test.dart +++ b/test/features/feed/data/feed_repository_cache_test.dart @@ -4,55 +4,53 @@ import 'dart:convert'; import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/get_timeline.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; -class _FakeFeedData { - _FakeFeedData({required this.feed, this.cursor}); +import '../../../helpers/test_bluesky_client.dart'; - final List feed; - final String? cursor; -} - -class _FakeFeedResponse { - _FakeFeedResponse(this.data); - - final _FakeFeedData data; -} +class _QueuedFeedTransport { + _QueuedFeedTransport({List? timelineResponses}) + : _timelineResponses = Queue.from(timelineResponses ?? const []); -class _QueuedFeedApi { - _QueuedFeedApi({List<_FakeFeedResponse>? timelineResponses}) - : _timelineResponses = Queue<_FakeFeedResponse>.from(timelineResponses ?? const []); + final Queue _timelineResponses; - final Queue<_FakeFeedResponse> _timelineResponses; + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.feed.getTimeline') { + return unexpectedGetClient(url, headers: headers); + } - Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map? $headers}) async { if (_timelineResponses.isEmpty) { - throw StateError('No timeline response queued for cursor=$cursor'); + throw StateError('No timeline response queued for cursor=${url.queryParameters['cursor']}'); } - return _timelineResponses.removeFirst(); + return jsonResponse(url, 'GET', _timelineResponses.removeFirst().toJson()); } } -class _HandlerFeedApi { - _HandlerFeedApi({required this.getTimelineHandler}); +class _HandlerFeedTransport { + _HandlerFeedTransport({required this.getTimelineHandler}); - final Future<_FakeFeedResponse> Function({String? cursor, int? limit, Map? headers}) + final Future Function({String? cursor, int? limit, Map? headers}) getTimelineHandler; - Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map? $headers}) { - return getTimelineHandler(cursor: cursor, limit: limit, headers: $headers); - } -} - -class _FakeBluesky { - _FakeBluesky(this.feed); + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.feed.getTimeline') { + return unexpectedGetClient(url, headers: headers); + } - final dynamic feed; + final output = await getTimelineHandler( + cursor: url.queryParameters['cursor'], + limit: int.tryParse(url.queryParameters['limit'] ?? ''), + headers: headers, + ); + return jsonResponse(url, 'GET', output.toJson()); + } } void main() { @@ -68,13 +66,17 @@ void main() { group('FeedRepository cache window', () { test('pagination deduplicates by URI and appends older posts', () async { - final feedApi = _QueuedFeedApi( + final feedApi = _QueuedFeedTransport( timelineResponses: [ - _FakeFeedResponse(_FakeFeedData(feed: [_post(100), _post(99), _post(98)], cursor: 'cursor-1')), - _FakeFeedResponse(_FakeFeedData(feed: [_post(98), _post(97), _post(96)], cursor: 'cursor-2')), + FeedGetTimelineOutput(feed: [_post(100), _post(99), _post(98)], cursor: 'cursor-1'), + FeedGetTimelineOutput(feed: [_post(98), _post(97), _post(96)], cursor: 'cursor-2'), ], ); - final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); + final repository = FeedRepository( + bluesky: testBluesky(getClient: feedApi.get), + database: database, + accountDid: 'did:plc:test', + ); await repository.getTimeline(); await repository.getTimeline(cursor: 'cursor-1'); @@ -86,13 +88,17 @@ void main() { }); test('refresh prepends latest page while preserving older cached posts', () async { - final feedApi = _QueuedFeedApi( + final feedApi = _QueuedFeedTransport( timelineResponses: [ - _FakeFeedResponse(_FakeFeedData(feed: [_post(30), _post(29), _post(28)], cursor: 'cursor-old')), - _FakeFeedResponse(_FakeFeedData(feed: [_post(40), _post(29), _post(39)], cursor: 'cursor-new')), + FeedGetTimelineOutput(feed: [_post(30), _post(29), _post(28)], cursor: 'cursor-old'), + FeedGetTimelineOutput(feed: [_post(40), _post(29), _post(39)], cursor: 'cursor-new'), ], ); - final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); + final repository = FeedRepository( + bluesky: testBluesky(getClient: feedApi.get), + database: database, + accountDid: 'did:plc:test', + ); await repository.getTimeline(); await repository.getTimeline(); @@ -105,8 +111,12 @@ void main() { test('refresh enforces OfflineCachePolicy feed post cap', () async { final posts = List.generate(OfflineCachePolicy.feedPostLimit + 10, _post); - final feedApi = _QueuedFeedApi(timelineResponses: [_FakeFeedResponse(_FakeFeedData(feed: posts, cursor: null))]); - final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); + final feedApi = _QueuedFeedTransport(timelineResponses: [FeedGetTimelineOutput(feed: posts)]); + final repository = FeedRepository( + bluesky: testBluesky(getClient: feedApi.get), + database: database, + accountDid: 'did:plc:test', + ); await repository.getTimeline(); @@ -121,8 +131,12 @@ void main() { }); test('getCachedFeedPage tolerates malformed cached posts and returns valid entries', () async { - final feedApi = _QueuedFeedApi(); - final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); + final feedApi = _QueuedFeedTransport(); + final repository = FeedRepository( + bluesky: testBluesky(getClient: feedApi.get), + database: database, + accountDid: 'did:plc:test', + ); final validPost = _post(2); await database.upsertCachedFeedPosts( @@ -157,27 +171,27 @@ void main() { var primaryCalls = 0; var fallbackCalls = 0; - final primaryFeedApi = _HandlerFeedApi( + final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; throw _unauthorizedException('app.bsky.feed.getTimeline'); }, ); - final fallbackFeedApi = _HandlerFeedApi( + final fallbackFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { fallbackCalls += 1; - return _FakeFeedResponse(_FakeFeedData(feed: [_post(1)], cursor: null)); + return FeedGetTimelineOutput(feed: [_post(1)]); }, ); final repository = FeedRepository( - bluesky: _FakeBluesky(primaryFeedApi), + bluesky: testBluesky(getClient: primaryFeedApi.get), database: database, accountDid: 'did:plc:test', onUnauthorized: () async { refreshCalls += 1; return _testTokens(); }, - blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), + blueskyClientFactory: (_) => testBluesky(getClient: fallbackFeedApi.get), ); final result = await repository.getTimeline(); @@ -192,14 +206,14 @@ void main() { test('rethrows unauthorized when recovery callback returns null tokens', () async { var refreshCalls = 0; var primaryCalls = 0; - final primaryFeedApi = _HandlerFeedApi( + final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; throw _unauthorizedException('app.bsky.feed.getTimeline'); }, ); final repository = FeedRepository( - bluesky: _FakeBluesky(primaryFeedApi), + bluesky: testBluesky(getClient: primaryFeedApi.get), database: database, accountDid: 'did:plc:test', onUnauthorized: () async { @@ -215,14 +229,14 @@ void main() { test('rethrows unauthorized when no recovery callback is configured', () async { var primaryCalls = 0; - final primaryFeedApi = _HandlerFeedApi( + final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; throw _unauthorizedException('app.bsky.feed.getTimeline'); }, ); final repository = FeedRepository( - bluesky: _FakeBluesky(primaryFeedApi), + bluesky: testBluesky(getClient: primaryFeedApi.get), database: database, accountDid: 'did:plc:test', ); diff --git a/test/features/feed/data/feed_repository_fallback_test.dart b/test/features/feed/data/feed_repository_fallback_test.dart index c6b3aaf..6cb055f 100644 --- a/test/features/feed/data/feed_repository_fallback_test.dart +++ b/test/features/feed/data/feed_repository_fallback_test.dart @@ -3,19 +3,20 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:mocktail/mocktail.dart'; -class _StubBluesky {} +import '../../../helpers/test_bluesky_client.dart'; class MockAppDatabase extends Mock implements AppDatabase {} void main() { - late _StubBluesky bluesky; + late Bluesky bluesky; late MockAppDatabase database; setUp(() { - bluesky = _StubBluesky(); + bluesky = testBluesky(); database = MockAppDatabase(); }); diff --git a/test/features/feed/data/liked_posts_repository_test.dart b/test/features/feed/data/liked_posts_repository_test.dart index 0124822..505cb0b 100644 --- a/test/features/feed/data/liked_posts_repository_test.dart +++ b/test/features/feed/data/liked_posts_repository_test.dart @@ -1,17 +1,20 @@ import 'dart:convert'; -import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/get_actor_likes.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; import 'package:lazurite/features/search/data/semantic_indexer.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../helpers/test_bluesky_client.dart'; + class MockSemanticIndexer extends Mock implements SemanticIndexer {} const _accountDid = 'did:plc:testuser'; @@ -34,7 +37,7 @@ void main() { final post1 = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post1'); final post2 = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post2'); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post1, post2]), @@ -68,7 +71,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post1, post2]), @@ -97,7 +100,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: pages)), + bluesky: _testBluesky(feed: _FakeFeedService(pages: pages)), database: database, ); @@ -120,7 +123,7 @@ void main() { } final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService(pages: [const FeedGetActorLikesOutput(feed: [])]), ), database: database, @@ -135,7 +138,7 @@ void main() { test('does not insert duplicate posts (idempotent)', () async { final post = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post1'); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post]), @@ -163,7 +166,7 @@ void main() { final fakeService = _FakeFeedService(pages: pages); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: fakeService), + bluesky: _testBluesky(feed: fakeService), database: database, ); @@ -193,7 +196,7 @@ void main() { ], ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: feed), + bluesky: _testBluesky(feed: feed), database: database, ); @@ -217,7 +220,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [_makeFeedViewPost(knownPostUri), _makeFeedViewPost(newPostUri)]), @@ -236,7 +239,7 @@ void main() { test('stores postJson as valid JSON', () async { final post = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post1'); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post]), @@ -256,7 +259,7 @@ void main() { test('isolates posts by accountDid', () async { final post = _makeFeedViewPost('at://did:plc:author/app.bsky.feed.post/post1'); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post]), @@ -275,7 +278,7 @@ void main() { test('updates existing liked row when incoming likedAt is newer', () async { const postUri = 'at://did:plc:author/app.bsky.feed.post/post1'; final firstRepo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput( @@ -289,7 +292,7 @@ void main() { await firstRepo.syncLikes(_accountDid); final secondRepo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput( @@ -311,7 +314,7 @@ void main() { group('getLikedPosts', () { test('returns empty list when no posts exist', () async { final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -332,7 +335,7 @@ void main() { } final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -366,7 +369,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -394,7 +397,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -417,7 +420,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -430,7 +433,7 @@ void main() { test('returns 0 when post does not exist', () async { final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -450,7 +453,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, ); @@ -475,7 +478,7 @@ void main() { const postUri = 'at://did:plc:author/app.bsky.feed.post/post1'; final post = _makeFeedViewPost(postUri); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post]), @@ -504,7 +507,7 @@ void main() { final post = _makeFeedViewPost(postUri); final repo = LikedPostsRepository( - bluesky: _FakeBluesky( + bluesky: _testBluesky( feed: _FakeFeedService( pages: [ FeedGetActorLikesOutput(feed: [post]), @@ -532,7 +535,7 @@ void main() { ); final repo = LikedPostsRepository( - bluesky: _FakeBluesky(feed: _FakeFeedService(pages: [])), + bluesky: _testBluesky(feed: _FakeFeedService(pages: [])), database: database, semanticIndexer: mockIndexer, ); @@ -660,11 +663,7 @@ FeedViewPost _makeFeedViewPost(String uriStr, {DateTime? indexedAt, DateTime? cr ); } -class _FakeBluesky { - _FakeBluesky({required this.feed}); - - final _FakeFeedService feed; -} +Bluesky _testBluesky({required _FakeFeedService feed}) => testBluesky(getClient: feed.get); class _FakeFeedService { _FakeFeedService({required this.pages}); @@ -673,23 +672,15 @@ class _FakeFeedService { int _callIndex = 0; int callCount = 0; - Future<_FakeXRPCResponse> getActorLikes({ - required String actor, - int? limit, - String? cursor, - String? $service, - Map? $headers, - }) async { + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.feed.getActorLikes') { + return unexpectedGetClient(url, headers: headers); + } + callCount++; if (_callIndex >= pages.length) { - return _FakeXRPCResponse(const FeedGetActorLikesOutput(feed: [])); + return jsonResponse(url, 'GET', const FeedGetActorLikesOutput(feed: []).toJson()); } - return _FakeXRPCResponse(pages[_callIndex++]); + return jsonResponse(url, 'GET', pages[_callIndex++].toJson()); } } - -class _FakeXRPCResponse { - _FakeXRPCResponse(this.data); - - final T data; -} diff --git a/test/features/feed/data/post_thread_repository_cache_test.dart b/test/features/feed/data/post_thread_repository_cache_test.dart index 3d8f8cd..398cbe7 100644 --- a/test/features/feed/data/post_thread_repository_cache_test.dart +++ b/test/features/feed/data/post_thread_repository_cache_test.dart @@ -6,40 +6,29 @@ import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/get_post_thread.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; -class _FakeThreadResponse { - _FakeThreadResponse(this.data); +import '../../../helpers/test_bluesky_client.dart'; - final FeedGetPostThreadOutput data; -} +class _FakeThreadFeedTransport { + _FakeThreadFeedTransport({required this.getPostThreadHandler}); -class _FakeThreadFeedApi { - _FakeThreadFeedApi({required this.getPostThreadHandler}); + final Future Function({required AtUri uri}) getPostThreadHandler; - final Future<_FakeThreadResponse> Function({required AtUri uri}) getPostThreadHandler; + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.feed.getPostThread') { + return unexpectedGetClient(url, headers: headers); + } - Future<_FakeThreadResponse> getPostThread({ - required AtUri uri, - int? depth, - int? parentHeight, - String? $service, - Map? $headers, - Map? $unknown, - }) { - return getPostThreadHandler(uri: uri); + final output = await getPostThreadHandler(uri: AtUri.parse(url.queryParameters['uri']!)); + return jsonResponse(url, 'GET', output.toJson()); } } -class _FakeBluesky { - _FakeBluesky(this.feed); - - final _FakeThreadFeedApi feed; -} - void main() { late AppDatabase database; @@ -59,12 +48,12 @@ void main() { post: childPost, parent: UThreadViewPostParent.threadViewPost(data: root), ); - final feedApi = _FakeThreadFeedApi( + final feedApi = _FakeThreadFeedTransport( getPostThreadHandler: ({required uri}) async => - _FakeThreadResponse(FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread))), + FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread)), ); final repository = PostThreadRepository( - bluesky: _FakeBluesky(feedApi), + bluesky: testBluesky(getClient: feedApi.get), database: database, accountDid: 'did:plc:test', ); @@ -88,9 +77,11 @@ void main() { rootUri: root.post.uri.toString(), payload: jsonEncode(root.toJson()), ); - final feedApi = _FakeThreadFeedApi(getPostThreadHandler: ({required uri}) async => throw Exception('offline')); + final feedApi = _FakeThreadFeedTransport( + getPostThreadHandler: ({required uri}) async => throw Exception('offline'), + ); final repository = PostThreadRepository( - bluesky: _FakeBluesky(feedApi), + bluesky: testBluesky(getClient: feedApi.get), database: database, accountDid: 'did:plc:test', ); @@ -113,12 +104,12 @@ void main() { } final newest = _thread(uri: 'at://did:plc:new/app.bsky.feed.post/newest', cid: 'cid-new', text: 'Newest'); - final feedApi = _FakeThreadFeedApi( + final feedApi = _FakeThreadFeedTransport( getPostThreadHandler: ({required uri}) async => - _FakeThreadResponse(FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: newest))), + FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: newest)), ); final repository = PostThreadRepository( - bluesky: _FakeBluesky(feedApi), + bluesky: testBluesky(getClient: feedApi.get), database: database, accountDid: 'did:plc:test', ); @@ -138,29 +129,27 @@ void main() { var fallbackCalls = 0; var refreshCalls = 0; final thread = _thread(uri: 'at://did:plc:retry/app.bsky.feed.post/retry', cid: 'cid-retry', text: 'Retry'); - final primaryFeedApi = _FakeThreadFeedApi( + final primaryFeedApi = _FakeThreadFeedTransport( getPostThreadHandler: ({required uri}) async { primaryCalls += 1; throw _unauthorizedException('app.bsky.feed.getPostThread'); }, ); - final fallbackFeedApi = _FakeThreadFeedApi( + final fallbackFeedApi = _FakeThreadFeedTransport( getPostThreadHandler: ({required uri}) async { fallbackCalls += 1; - return _FakeThreadResponse( - FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread)), - ); + return FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread)); }, ); final repository = PostThreadRepository( - bluesky: _FakeBluesky(primaryFeedApi), + bluesky: testBluesky(getClient: primaryFeedApi.get), database: database, accountDid: 'did:plc:test', onUnauthorized: () async { refreshCalls += 1; return _testTokens(); }, - blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), + blueskyClientFactory: (_) => testBluesky(getClient: fallbackFeedApi.get), ); final resolved = await repository.getPostThread(thread.post.uri.toString()); diff --git a/test/features/lists/bloc/list_bloc_test.dart b/test/features/lists/bloc/list_bloc_test.dart index d998ab5..32c407d 100644 --- a/test/features/lists/bloc/list_bloc_test.dart +++ b/test/features/lists/bloc/list_bloc_test.dart @@ -1,4 +1,4 @@ -import 'package:poptart_core/poptart_core.dart' show AtUri, BlobRef; +import 'package:poptart_core/poptart_core.dart' show AtUri, Blob, BlobRef; import 'package:bloc_test/bloc_test.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; @@ -238,7 +238,9 @@ void main() { bytes: any(named: 'bytes'), mimeType: any(named: 'mimeType'), ), - ).thenAnswer((_) async => const BlobRef(link: 'bafkreinewavatarblob')); + ).thenAnswer( + (_) async => const Blob(ref: BlobRef(link: 'bafkreinewavatarblob'), mimeType: 'image/jpeg', size: 3), + ); when( () => mockListRepository.updateList( listUri: any(named: 'listUri'), @@ -272,7 +274,7 @@ void main() { name: any(named: 'name'), purpose: any(named: 'purpose'), description: any(named: 'description'), - avatarBlob: const BlobRef(link: 'bafkreinewavatarblob'), + avatarBlob: const Blob(ref: BlobRef(link: 'bafkreinewavatarblob'), mimeType: 'image/jpeg', size: 3), ), ).called(1); }, diff --git a/test/features/lists/cubit/my_lists_cubit_test.dart b/test/features/lists/cubit/my_lists_cubit_test.dart index 3764c02..38511f1 100644 --- a/test/features/lists/cubit/my_lists_cubit_test.dart +++ b/test/features/lists/cubit/my_lists_cubit_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; -import 'package:poptart_core/poptart_core.dart' show AtUri, BlobRef; +import 'package:poptart_core/poptart_core.dart' show AtUri, Blob, BlobRef; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -144,21 +144,21 @@ void main() { test('createList uploads avatar when bytes provided', () async { final cubit = MyListsCubit(listRepository: mockListRepository); - const avatarRef = BlobRef(link: 'bafkreiavatarblob'); + const avatar = Blob(ref: BlobRef(link: 'bafkreiavatarblob'), mimeType: 'image/jpeg', size: 3); when( () => mockListRepository.uploadListAvatar( bytes: any(named: 'bytes'), mimeType: any(named: 'mimeType'), ), - ).thenAnswer((_) async => avatarRef); + ).thenAnswer((_) async => avatar); when( () => mockListRepository.createList( userDid: actor, name: 'With Avatar', purpose: 'app.bsky.graph.defs#modlist', description: any(named: 'description'), - avatarBlob: avatarRef, + avatarBlob: avatar, ), ).thenAnswer((_) async => newListUri); diff --git a/test/features/lists/data/list_repository_test.dart b/test/features/lists/data/list_repository_test.dart index e4540e9..25a8f6f 100644 --- a/test/features/lists/data/list_repository_test.dart +++ b/test/features/lists/data/list_repository_test.dart @@ -1,33 +1,40 @@ +import 'dart:convert'; import 'dart:typed_data'; -import 'package:poptart_core/poptart_core.dart' show AtUri, Blob, BlobRef; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/search_actors_typeahead.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/get_list_feed.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/get_list.dart'; import 'package:poptart_lex/app/bsky/graph/get_lists.dart'; import 'package:poptart_lex/app/bsky/graph/get_lists_with_membership.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:poptart_lex/com/atproto/repo/create_record.dart'; +import 'package:poptart_lex/com/atproto/repo/delete_record.dart'; +import 'package:poptart_lex/com/atproto/repo/put_record.dart'; +import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; void main() { - late _FakeGraphService graph; - late _FakeFeedService feed; - late _FakeActorService actor; + late _FakeXrpcTransport transport; late ListRepository repository; final listUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/list-1'); final listItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); - late _FakeAtprotoService atproto; - setUp(() { - graph = _FakeGraphService(); - feed = _FakeFeedService(); - actor = _FakeActorService(); - atproto = _FakeAtprotoService(); + transport = _FakeXrpcTransport(); repository = ListRepository( - bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor, atproto: atproto), + bluesky: Bluesky.fromSession( + _session, + service: 'bsky.social', + getClient: transport.get, + postClient: transport.post, + ), ); }); @@ -52,103 +59,106 @@ void main() { ); test('getLists requests curation and moderation lists by default', () async { - graph.getListsResult = _FakeListsData(lists: [listView], cursor: 'cursor-1'); + transport.getListsResult = GraphGetListsOutput(lists: [listView], cursor: 'cursor-1'); final result = await repository.getLists(actor: 'did:plc:creator', limit: 25); expect(result.lists, [listView]); expect(result.cursor, 'cursor-1'); - expect(graph.lastGetListsActor, 'did:plc:creator'); - expect(graph.lastGetListsLimit, 25); - expect(graph.lastGetListsPurposes?.map((purpose) => purpose.toJson()).toList(), ['curatelist', 'modlist']); + expect(transport.lastGetListsActor, 'did:plc:creator'); + expect(transport.lastGetListsLimit, 25); + expect(transport.lastGetListsPurposes?.map((purpose) => purpose.toJson()).toList(), ['curatelist', 'modlist']); }); test('getList returns the hydrated list and members', () async { - graph.getListResult = _FakeListData(list: listView, items: [listItem], cursor: null); + transport.getListResult = GraphGetListOutput(list: listView, items: [listItem]); final result = await repository.getList(listUri: listUri); expect(result.list, listView); expect(result.items, [listItem]); - expect(graph.lastGetListUri, listUri); + expect(transport.lastGetListUri, listUri); }); test('getListFeed returns feed posts and cursor', () async { - feed.getListFeedResult = _FakeListFeedData(feed: [feedPost], cursor: 'cursor-2'); + transport.getListFeedResult = FeedGetListFeedOutput(feed: [feedPost], cursor: 'cursor-2'); final result = await repository.getListFeed(listUri: listUri); expect(result.posts, [feedPost]); expect(result.cursor, 'cursor-2'); - expect(feed.lastListUri, listUri); + expect(transport.lastListUri, listUri); }); test('getListsWithMembership returns membership records', () async { - graph.getListsWithMembershipResult = _FakeListsWithMembershipData( + transport.getListsWithMembershipResult = GraphGetListsWithMembershipOutput( listsWithMembership: [ListWithMembership(list: listView, listItem: listItem)], - cursor: null, ); final result = await repository.getListsWithMembership(actor: 'did:plc:member-1'); expect(result.lists.length, 1); expect(result.lists.single.listItem, listItem); - expect(graph.lastGetListsWithMembershipPurposes?.map((purpose) => purpose.toJson()).toList(), [ + expect(transport.lastGetListsWithMembershipPurposes?.map((purpose) => purpose.toJson()).toList(), [ 'curatelist', 'modlist', ]); }); test('searchActorsTypeahead returns matching actors', () async { - actor.searchActorsResult = const _FakeActorsData( + transport.searchActorsResult = const ActorSearchActorsTypeaheadOutput( actors: [ProfileViewBasic(did: 'did:plc:member-1', handle: 'member1.bsky.social')], ); final result = await repository.searchActorsTypeahead(query: 'member', limit: 5); expect(result.single.did, 'did:plc:member-1'); - expect(actor.lastQuery, 'member'); - expect(actor.lastLimit, 5); + expect(transport.lastQuery, 'member'); + expect(transport.lastLimit, 5); }); test('add and remove list members call the record accessors', () async { - graph.listitem.createdUri = listItemUri; + transport.createdListItemUri = listItemUri; final createdUri = await repository.addListItem(listUri: listUri, subjectDid: 'did:plc:member-1'); await repository.removeListItem(listItemUri: listItemUri); expect(createdUri, listItemUri.toString()); - expect(graph.listitem.lastCreatedList, listUri); - expect(graph.listitem.lastCreatedSubject, 'did:plc:member-1'); - expect(graph.listitem.lastDeletedRkey, listItemUri.rkey); + expect(transport.lastCreateCollection, 'app.bsky.graph.listitem'); + expect(transport.lastCreateRecord?['list'], listUri.toString()); + expect(transport.lastCreateRecord?['subject'], 'did:plc:member-1'); + expect(transport.lastDeleteCollection, 'app.bsky.graph.listitem'); + expect(transport.lastDeleteRkey, listItemUri.rkey); }); test('mute and unmute list call graph endpoints', () async { await repository.muteList(listUri: listUri); await repository.unmuteList(listUri: listUri); - expect(graph.lastMutedList, listUri); - expect(graph.lastUnmutedList, listUri); + expect(transport.lastMutedList, listUri); + expect(transport.lastUnmutedList, listUri); }); test('block and unblock list call listblock accessors', () async { - graph.listblock.createdUri = blockUri; + transport.createdBlockUri = blockUri; final createdUri = await repository.blockList(listUri: listUri); await repository.unblockList(blockUri: blockUri); expect(createdUri, blockUri.toString()); - expect(graph.listblock.lastCreatedSubject, listUri); - expect(graph.listblock.lastDeletedRkey, blockUri.rkey); + expect(transport.lastCreateCollection, 'app.bsky.graph.listblock'); + expect(transport.lastCreateRecord?['subject'], listUri.toString()); + expect(transport.lastDeleteCollection, 'app.bsky.graph.listblock'); + expect(transport.lastDeleteRkey, blockUri.rkey); }); - test('uploadListAvatar uploads bytes and returns BlobRef', () async { + test('uploadListAvatar uploads bytes and returns Blob', () async { final bytes = [1, 2, 3, 4]; - final ref = await repository.uploadListAvatar(bytes: bytes, mimeType: 'image/png'); + final blob = await repository.uploadListAvatar(bytes: bytes, mimeType: 'image/png'); - expect(ref, atproto.repo.uploadedBlobRef); - expect(atproto.repo.lastUploadedBytes, Uint8List.fromList(bytes)); - expect(atproto.repo.lastUploadHeaders, {'Content-Type': 'image/png'}); + expect(blob, transport.uploadedBlob); + expect(transport.lastUploadedBytes, Uint8List.fromList(bytes)); + expect(transport.lastUploadHeaders, containsPair('Content-Type', 'image/png')); }); test('createList creates a record and returns the new URI', () async { @@ -159,26 +169,30 @@ void main() { description: 'A great list', ); - expect(createdUri, atproto.repo.createdListUri); - expect(atproto.repo.lastCreateRepo, 'did:plc:creator'); - expect(atproto.repo.lastCreateCollection, 'app.bsky.graph.list'); - expect(atproto.repo.lastCreateRecord?[r'$type'], 'app.bsky.graph.list'); - expect(atproto.repo.lastCreateRecord?['name'], 'My List'); - expect(atproto.repo.lastCreateRecord?['purpose'], 'app.bsky.graph.defs#curatelist'); - expect(atproto.repo.lastCreateRecord?['description'], 'A great list'); + expect(createdUri, transport.createdListUri); + expect(transport.lastCreateRepo, 'did:plc:creator'); + expect(transport.lastCreateCollection, 'app.bsky.graph.list'); + expect(transport.lastCreateRecord?[r'$type'], 'app.bsky.graph.list'); + expect(transport.lastCreateRecord?['name'], 'My List'); + expect(transport.lastCreateRecord?['purpose'], 'app.bsky.graph.defs#curatelist'); + expect(transport.lastCreateRecord?['description'], 'A great list'); }); test('createList embeds avatar blob when provided', () async { - const blobRef = BlobRef(link: 'bafkreiavatarblob'); + const avatarBlob = Blob( + ref: BlobRef(link: 'bafkreiavatarblob'), + mimeType: 'image/jpeg', + size: 1, + ); await repository.createList( userDid: 'did:plc:creator', name: 'List With Avatar', purpose: 'app.bsky.graph.defs#modlist', - avatarBlob: blobRef, + avatarBlob: avatarBlob, ); - expect(atproto.repo.lastCreateRecord?['avatar'], blobRef.toJson()); + expect(transport.lastCreateRecord?['avatar'], avatarBlob.toJson()); }); test('updateList puts an updated record', () async { @@ -190,23 +204,30 @@ void main() { description: 'Updated description', ); - expect(atproto.repo.lastPutRepo, 'did:plc:creator'); - expect(atproto.repo.lastPutCollection, 'app.bsky.graph.list'); - expect(atproto.repo.lastPutRkey, listUri.rkey); - expect(atproto.repo.lastPutRecord?['name'], 'Updated Name'); - expect(atproto.repo.lastPutRecord?['description'], 'Updated description'); + expect(transport.lastPutRepo, 'did:plc:creator'); + expect(transport.lastPutCollection, 'app.bsky.graph.list'); + expect(transport.lastPutRkey, listUri.rkey); + expect(transport.lastPutRecord?['name'], 'Updated Name'); + expect(transport.lastPutRecord?['description'], 'Updated description'); }); test('deleteList deletes the record by rkey', () async { await repository.deleteList(listUri: listUri, userDid: 'did:plc:creator'); - expect(atproto.repo.lastDeleteRepo, 'did:plc:creator'); - expect(atproto.repo.lastDeleteCollection, 'app.bsky.graph.list'); - expect(atproto.repo.lastDeleteRkey, listUri.rkey); + expect(transport.lastDeleteRepo, 'did:plc:creator'); + expect(transport.lastDeleteCollection, 'app.bsky.graph.list'); + expect(transport.lastDeleteRkey, listUri.rkey); }); }); } +const _session = Session( + did: 'did:plc:creator', + handle: 'creator.bsky.social', + accessJwt: 'access-token', + refreshJwt: 'refresh-token', +); + ListView _buildListView(AtUri uri) { return ListView( uri: uri, @@ -218,25 +239,30 @@ ListView _buildListView(AtUri uri) { ); } -class _FakeBlueskyClient { - _FakeBlueskyClient({required this.graph, required this.feed, required this.actor, _FakeAtprotoService? atproto}) - : atproto = atproto ?? _FakeAtprotoService(); - - final _FakeGraphService graph; - final _FakeFeedService feed; - final _FakeActorService actor; - final _FakeAtprotoService atproto; -} - -class _FakeAtprotoService { - _FakeAtprotoService() : repo = _FakeRepoService(); - - final _FakeRepoService repo; -} +class _FakeXrpcTransport { + GraphGetListsOutput? getListsResult; + GraphGetListOutput? getListResult; + FeedGetListFeedOutput? getListFeedResult; + GraphGetListsWithMembershipOutput? getListsWithMembershipResult; + ActorSearchActorsTypeaheadOutput? searchActorsResult; + + AtUri createdListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/created-list'); + AtUri createdListItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-created'); + AtUri createdBlockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-created'); + Blob uploadedBlob = const Blob( + ref: BlobRef(link: 'bafkreitestblobref'), + mimeType: 'image/jpeg', + size: 4, + ); -class _FakeRepoService { - final AtUri createdListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/created-list'); - final BlobRef uploadedBlobRef = const BlobRef(link: 'bafkreitestblobref'); + String? lastGetListsActor; + int? lastGetListsLimit; + List? lastGetListsPurposes; + AtUri? lastGetListUri; + AtUri? lastListUri; + List? lastGetListsWithMembershipPurposes; + String? lastQuery; + int? lastLimit; String? lastCreateRepo; String? lastCreateCollection; @@ -251,246 +277,121 @@ class _FakeRepoService { String? lastDeleteCollection; String? lastDeleteRkey; - Uint8List? lastUploadedBytes; - Map? lastUploadHeaders; - - Future<_FakeResponse<_FakeCreateRecordData>> createRecord({ - required String repo, - required String collection, - required Map record, - String? rkey, - Map? $headers, - }) async { - lastCreateRepo = repo; - lastCreateCollection = collection; - lastCreateRecord = record; - return _FakeResponse(_FakeCreateRecordData(createdListUri)); - } - - Future<_FakeResponse> putRecord({ - required String repo, - required String collection, - required String rkey, - required Map record, - Map? $headers, - }) async { - lastPutRepo = repo; - lastPutCollection = collection; - lastPutRkey = rkey; - lastPutRecord = record; - return _FakeResponse(Object()); - } - - Future<_FakeResponse> deleteRecord({ - required String repo, - required String collection, - required String rkey, - Map? $headers, - }) async { - lastDeleteRepo = repo; - lastDeleteCollection = collection; - lastDeleteRkey = rkey; - return _FakeResponse(Object()); - } - - Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ - required Uint8List bytes, - Map? $headers, - }) async { - lastUploadedBytes = bytes; - lastUploadHeaders = $headers; - return _FakeResponse(_FakeUploadBlobData(Blob(mimeType: 'image/jpeg', size: bytes.length, ref: uploadedBlobRef))); - } -} - -class _FakeCreateRecordData { - const _FakeCreateRecordData(this.uri); - - final AtUri uri; -} - -class _FakeUploadBlobData { - const _FakeUploadBlobData(this.blob); - - final Blob blob; -} - -class _FakeGraphService { - _FakeGraphService() : listitem = _FakeListitemAccessor(), listblock = _FakeListblockAccessor(); - - _FakeListsData? getListsResult; - _FakeListData? getListResult; - _FakeListsWithMembershipData? getListsWithMembershipResult; - - String? lastGetListsActor; - int? lastGetListsLimit; - List? lastGetListsPurposes; - AtUri? lastGetListUri; AtUri? lastMutedList; AtUri? lastUnmutedList; - List? lastGetListsWithMembershipPurposes; - final _FakeListitemAccessor listitem; - final _FakeListblockAccessor listblock; - - Future<_FakeResponse<_FakeListsData>> getLists({ - required String actor, - int? limit, - String? cursor, - List? purposes, - Map? $headers, - }) async { - lastGetListsActor = actor; - lastGetListsLimit = limit; - lastGetListsPurposes = purposes; - return _FakeResponse(getListsResult!); - } + Uint8List? lastUploadedBytes; + Map? lastUploadHeaders; - Future<_FakeResponse<_FakeListData>> getList({ - required AtUri list, - int? limit, - String? cursor, - Map? $headers, - }) async { - lastGetListUri = list; - return _FakeResponse(getListResult!); + Future get(Uri url, {Map? headers}) async { + final query = url.queryParameters; + + switch (url.pathSegments.last) { + case 'app.bsky.graph.getLists': + lastGetListsActor = query['actor']; + lastGetListsLimit = int.tryParse(query['limit'] ?? ''); + lastGetListsPurposes = _getListPurposes(url.queryParametersAll['purposes'] ?? const []); + return _jsonResponse(url, 'GET', getListsResult!.toJson()); + case 'app.bsky.graph.getList': + lastGetListUri = AtUri.parse(query['list']!); + return _jsonResponse(url, 'GET', getListResult!.toJson()); + case 'app.bsky.feed.getListFeed': + lastListUri = AtUri.parse(query['list']!); + return _jsonResponse(url, 'GET', getListFeedResult!.toJson()); + case 'app.bsky.graph.getListsWithMembership': + lastGetListsWithMembershipPurposes = _getMembershipPurposes(url.queryParametersAll['purposes'] ?? const []); + return _jsonResponse(url, 'GET', getListsWithMembershipResult!.toJson()); + case 'app.bsky.actor.searchActorsTypeahead': + lastQuery = query['q']; + lastLimit = int.tryParse(query['limit'] ?? ''); + return _jsonResponse(url, 'GET', searchActorsResult!.toJson()); + default: + throw StateError('Unexpected GET ${url.path}'); + } } - Future<_FakeResponse<_FakeListsWithMembershipData>> getListsWithMembership({ - required String actor, - int? limit, - String? cursor, - List? purposes, - Map? $headers, - }) async { - lastGetListsWithMembershipPurposes = purposes; - return _FakeResponse(getListsWithMembershipResult!); + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + switch (url.pathSegments.last) { + case 'com.atproto.repo.createRecord': + final input = _decodeJsonBody(body); + lastCreateRepo = input['repo'] as String?; + lastCreateCollection = input['collection'] as String?; + lastCreateRecord = (input['record'] as Map).cast(); + return _jsonResponse( + url, + 'POST', + RepoCreateRecordOutput(uri: _createdUriFor(lastCreateCollection), cid: 'cid-created').toJson(), + ); + case 'com.atproto.repo.putRecord': + final input = _decodeJsonBody(body); + lastPutRepo = input['repo'] as String?; + lastPutCollection = input['collection'] as String?; + lastPutRkey = input['rkey'] as String?; + lastPutRecord = (input['record'] as Map).cast(); + return _jsonResponse( + url, + 'POST', + RepoPutRecordOutput( + uri: AtUri.parse('at://$lastPutRepo/$lastPutCollection/$lastPutRkey'), + cid: 'cid-put', + ).toJson(), + ); + case 'com.atproto.repo.deleteRecord': + final input = _decodeJsonBody(body); + lastDeleteRepo = input['repo'] as String?; + lastDeleteCollection = input['collection'] as String?; + lastDeleteRkey = input['rkey'] as String?; + return _jsonResponse(url, 'POST', const RepoDeleteRecordOutput().toJson()); + case 'com.atproto.repo.uploadBlob': + lastUploadedBytes = Uint8List.fromList((body as List?) ?? const []); + lastUploadHeaders = headers; + return _jsonResponse(url, 'POST', RepoUploadBlobOutput(blob: uploadedBlob).toJson()); + case 'app.bsky.graph.muteActorList': + lastMutedList = AtUri.parse(_decodeJsonBody(body)['list'] as String); + return _jsonResponse(url, 'POST', const {}); + case 'app.bsky.graph.unmuteActorList': + lastUnmutedList = AtUri.parse(_decodeJsonBody(body)['list'] as String); + return _jsonResponse(url, 'POST', const {}); + default: + throw StateError('Unexpected POST ${url.path}'); + } } - Future muteActorList({required AtUri list, Map? $headers}) async { - lastMutedList = list; + AtUri _createdUriFor(String? collection) { + return switch (collection) { + 'app.bsky.graph.list' => createdListUri, + 'app.bsky.graph.listitem' => createdListItemUri, + 'app.bsky.graph.listblock' => createdBlockUri, + _ => AtUri.parse('at://did:plc:creator/${collection ?? 'unknown'}/created'), + }; } - Future unmuteActorList({required AtUri list, Map? $headers}) async { - lastUnmutedList = list; + List _getListPurposes(List values) { + return values.map((value) => GraphGetListsPurposes.valueOf(value)!).toList(growable: false); } -} -class _FakeFeedService { - _FakeListFeedData? getListFeedResult; - AtUri? lastListUri; - - Future<_FakeResponse<_FakeListFeedData>> getListFeed({ - required AtUri list, - int? limit, - String? cursor, - Map? $headers, - }) async { - lastListUri = list; - return _FakeResponse(getListFeedResult!); + List _getMembershipPurposes(List values) { + return values.map((value) => GraphGetListsWithMembershipPurposes.valueOf(value)!).toList(growable: false); } -} -class _FakeActorService { - _FakeActorsData? searchActorsResult; - String? lastQuery; - int? lastLimit; - - Future<_FakeResponse<_FakeActorsData>> searchActorsTypeahead({ - required String q, - int? limit, - Map? $headers, - }) async { - lastQuery = q; - lastLimit = limit; - return _FakeResponse(searchActorsResult!); - } -} + Map _decodeJsonBody(Object? body) { + if (body is String) { + return (jsonDecode(body) as Map).cast(); + } -class _FakeListitemAccessor { - AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-created'); - AtUri? lastCreatedList; - String? lastCreatedSubject; - String? lastDeletedRkey; - - Future<_FakeResponse<_FakeUriData>> create({ - required String subject, - required AtUri list, - DateTime? createdAt, - Map? $headers, - }) async { - lastCreatedList = list; - lastCreatedSubject = subject; - return _FakeResponse(_FakeUriData(createdUri)); - } + if (body == null) { + return const {}; + } - Future delete({required String rkey, Map? $headers}) async { - lastDeletedRkey = rkey; + throw ArgumentError.value(body, 'body', 'Expected a JSON string body.'); } -} -class _FakeListblockAccessor { - AtUri createdUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-created'); - AtUri? lastCreatedSubject; - String? lastDeletedRkey; - - Future<_FakeResponse<_FakeUriData>> create({ - required AtUri subject, - DateTime? createdAt, - Map? $headers, - }) async { - lastCreatedSubject = subject; - return _FakeResponse(_FakeUriData(createdUri)); - } - - Future delete({required String rkey, Map? $headers}) async { - lastDeletedRkey = rkey; + http.Response _jsonResponse(Uri url, String method, Map body) { + return http.Response( + jsonEncode(body), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + request: http.Request(method, url), + ); } } - -class _FakeResponse { - _FakeResponse(this.data); - - final T data; -} - -class _FakeListsData { - const _FakeListsData({required this.lists, this.cursor}); - - final List lists; - final String? cursor; -} - -class _FakeListData { - const _FakeListData({required this.list, required this.items, this.cursor}); - - final ListView list; - final List items; - final String? cursor; -} - -class _FakeListsWithMembershipData { - const _FakeListsWithMembershipData({required this.listsWithMembership, this.cursor}); - - final List listsWithMembership; - final String? cursor; -} - -class _FakeListFeedData { - const _FakeListFeedData({required this.feed, this.cursor}); - - final List feed; - final String? cursor; -} - -class _FakeActorsData { - const _FakeActorsData({required this.actors}); - - final List actors; -} - -class _FakeUriData { - const _FakeUriData(this.uri); - - final AtUri uri; -} diff --git a/test/features/moderation/data/moderation_service_test.dart b/test/features/moderation/data/moderation_service_test.dart index 8ad18b6..0f74631 100644 --- a/test/features/moderation/data/moderation_service_test.dart +++ b/test/features/moderation/data/moderation_service_test.dart @@ -1,16 +1,21 @@ import 'dart:convert'; import 'package:poptart_lex/com/atproto/label/defs.dart'; -import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/get_preferences.dart'; +import 'package:poptart_lex/app/bsky/actor/put_preferences.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/labeler/defs.dart'; import 'package:poptart_lex/app/bsky/labeler/get_services.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import '../../../helpers/test_bluesky_client.dart'; + const _customLabelerDid = 'did:plc:custom-labeler'; const _accountDid = 'did:plc:test-user'; @@ -28,7 +33,7 @@ void main() { group('ModerationService', () { test('initializes moderation opts, userDid, and accepted labeler headers', () async { final service = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( preferences: [ const UPreferences.adultContentPref(data: AdultContentPref(enabled: false)), @@ -56,7 +61,7 @@ void main() { test('filters labeled posts in list contexts', () async { final service = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( preferences: [const UPreferences.adultContentPref(data: AdultContentPref(enabled: false))], ), @@ -96,7 +101,7 @@ void main() { test('falls back to cached preferences after a request failure', () async { final seededService = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( preferences: [ const UPreferences.labelersPref( @@ -114,7 +119,7 @@ void main() { seededService.dispose(); final fallbackService = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(error: Exception('offline')), labeler: _FakeLabelerService(error: Exception('offline')), ), @@ -134,7 +139,7 @@ void main() { test('subscribeToLabeler writes updated preferences and refreshes headers', () async { final actor = _FakeActorService(preferences: const []); final service = ModerationService( - bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + bluesky: _testBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), database: database, accountDid: _accountDid, userDid: _accountDid, @@ -161,7 +166,7 @@ void main() { test('does not force AppView proxy headers for preference reads and writes', () async { final actor = _FakeActorService(preferences: const []); final service = ModerationService( - bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + bluesky: _testBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), database: database, accountDid: _accountDid, userDid: _accountDid, @@ -191,7 +196,7 @@ void main() { ), ); final service = ModerationService( - bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + bluesky: _testBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), database: database, accountDid: _accountDid, userDid: _accountDid, @@ -212,7 +217,7 @@ void main() { test('setLabelPreference stores contentLabelPref entries', () async { final actor = _FakeActorService(preferences: const []); final service = ModerationService( - bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + bluesky: _testBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), database: database, accountDid: _accountDid, userDid: _accountDid, @@ -238,7 +243,7 @@ void main() { }); test('dispose is idempotent', () { - final service = ModerationService(bluesky: _FakeBlueskyClient()); + final service = ModerationService(bluesky: _testBlueskyClient()); expect(() => service.dispose(), returnsNormally); expect(() => service.dispose(), returnsNormally); @@ -246,7 +251,7 @@ void main() { test('caches moderation preferences in the settings table', () async { final service = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( preferences: [ const UPreferences.labelersPref( @@ -273,7 +278,7 @@ void main() { test('resolves localized custom label names from labeler policies', () async { final service = ModerationService( - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( preferences: [ const UPreferences.labelersPref( @@ -317,13 +322,48 @@ void main() { }); } -class _FakeBlueskyClient { - _FakeBlueskyClient({_FakeActorService? actor, _FakeLabelerService? labeler}) +Bluesky _testBlueskyClient({_FakeActorService? actor, _FakeLabelerService? labeler}) { + final transport = _FakeModerationTransport( + actor: actor ?? _FakeActorService(), + labeler: labeler ?? const _FakeLabelerService(), + ); + return testBluesky(getClient: transport.get, postClient: transport.post); +} + +class _FakeModerationTransport { + _FakeModerationTransport({_FakeActorService? actor, _FakeLabelerService? labeler}) : actor = actor ?? _FakeActorService(), labeler = labeler ?? const _FakeLabelerService(); final _FakeActorService actor; final _FakeLabelerService labeler; + + Future get(Uri url, {Map? headers}) async { + switch (url.pathSegments.last) { + case 'app.bsky.actor.getPreferences': + final response = await actor.getPreferences($headers: headers); + return jsonResponse(url, 'GET', ActorGetPreferencesOutput(preferences: response.data.preferences).toJson()); + case 'app.bsky.labeler.getServices': + final response = await labeler.getServices( + dids: url.queryParametersAll['dids'] ?? const [], + detailed: url.queryParameters['detailed'] == 'true', + $headers: headers, + ); + return jsonResponse(url, 'GET', LabelerGetServicesOutput(views: response.data.views).toJson()); + default: + return unexpectedGetClient(url, headers: headers); + } + } + + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + if (url.pathSegments.last != 'app.bsky.actor.putPreferences') { + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + + final input = ActorPutPreferencesInput.fromJson((jsonDecode(body as String) as Map).cast()); + await actor.putPreferences(preferences: input.preferences, $headers: headers); + return jsonResponse(url, 'POST', const {}); + } } class _FakeActorService { diff --git a/test/features/profile/data/follow_audit_repository_test.dart b/test/features/profile/data/follow_audit_repository_test.dart index be4d782..26f0b08 100644 --- a/test/features/profile/data/follow_audit_repository_test.dart +++ b/test/features/profile/data/follow_audit_repository_test.dart @@ -1,9 +1,17 @@ +import 'dart:convert'; + import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; import 'package:poptart_core/poptart_core.dart' show AtUri; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/get_profiles.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; +import '../../../helpers/test_bluesky_client.dart'; + ProfileView _profile(String did, String handle, {bool blockedBy = false, bool blocking = false}) { final viewer = ViewerState( blockedBy: blockedBy ? true : null, @@ -18,11 +26,9 @@ FollowRecord _followRecord(String did, {String? rkey}) { return FollowRecord(uri: 'at://did:plc:owner/app.bsky.graph.follow/$k', rkey: k, subjectDid: did); } -class _FakeBluesky { - _FakeBluesky({required this.atproto, required this.actor}); - - final _FakeAtProtoClient atproto; - final _FakeActorService actor; +Bluesky _fakeBluesky({required _FakeAtProtoClient atproto, required _FakeActorService actor}) { + final transport = _FollowAuditTransport(actor: actor, repo: atproto.repo); + return testBluesky(did: _ownerDid, handle: 'owner.bsky.social', getClient: transport.get, postClient: transport.post); } class _FakeAtProtoClient { @@ -71,6 +77,105 @@ class _FakeRepoService { } } +class _FollowAuditTransport { + const _FollowAuditTransport({required this.actor, required this.repo}); + + final _FakeActorService actor; + final _FakeRepoService repo; + + Future get(Uri url, {Map? headers}) async { + final query = url.queryParameters; + switch (url.pathSegments.last) { + case 'app.bsky.actor.getProfiles': + final response = await actor.getProfiles( + actors: url.queryParametersAll['actors'] ?? const [], + $service: url.host, + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + ActorGetProfilesOutput( + profiles: response.data.profiles.map(_toDetailedProfile).toList(growable: false), + ).toJson(), + ); + case 'app.bsky.actor.getProfile': + final response = await actor.getProfile(actor: query['actor']!, $service: url.host, $headers: headers); + return jsonResponse(url, 'GET', _toDetailedProfile(response.data).toJson()); + case 'com.atproto.repo.listRecords': + final response = await repo.listRecords( + repo: query['repo']!, + collection: query['collection']!, + limit: int.tryParse(query['limit'] ?? '') ?? 100, + cursor: query['cursor'], + ); + return jsonResponse( + url, + 'GET', + RepoListRecordsOutput( + records: [ + for (var i = 0; i < response.data.records.length; i++) + RepoListRecordsRecord( + uri: AtUri.parse(response.data.records[i].uri), + cid: 'cid-$i', + value: _followRecordValue(response.data.records[i].value), + ), + ], + cursor: response.data.cursor, + ).toJson(), + ); + default: + return unexpectedGetClient(url, headers: headers); + } + } + + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + if (url.pathSegments.last != 'com.atproto.repo.applyWrites') { + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + + final input = RepoApplyWritesInput.fromJson((jsonDecode(body as String) as Map).cast()); + await repo.applyWrites( + repo: input.repo, + writes: input.writes, + validate: input.validate, + swapCommit: input.swapCommit, + ); + return jsonResponse(url, 'POST', const RepoApplyWritesOutput().toJson()); + } +} + +ProfileViewDetailed _toDetailedProfile(dynamic profile) { + if (profile is ProfileViewDetailed) { + return profile; + } + if (profile is ProfileView) { + return ProfileViewDetailed( + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + description: profile.description, + avatar: profile.avatar, + associated: profile.associated, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt, + viewer: profile.viewer, + labels: profile.labels, + verification: profile.verification, + status: profile.status, + debug: profile.debug, + ); + } + throw ArgumentError.value(profile, 'profile', 'Expected ProfileView or ProfileViewDetailed'); +} + +Map _followRecordValue(Map value) { + if (value.containsKey(r'$type')) { + return value; + } + return {r'$type': 'app.bsky.graph.follow', 'createdAt': '2026-01-01T00:00:00.000Z', ...value}; +} + class _FakeApplyWritesCall { const _FakeApplyWritesCall({required this.repo, required this.writes}); @@ -159,7 +264,7 @@ class _FakeResponse { final T data; } -_FakeBluesky _bluesky({ +Bluesky _bluesky({ List> pages = const [], Map batchProfiles = const {}, Map singleProfiles = const {}, @@ -168,7 +273,7 @@ _FakeBluesky _bluesky({ int batchFailCount = 0, void Function(String repo, List writes)? applyWritesCallback, }) { - return _FakeBluesky( + return _fakeBluesky( atproto: _FakeAtProtoClient( repo: _FakeRepoService(pages: pages, applyWritesCallback: applyWritesCallback), ), @@ -182,7 +287,7 @@ _FakeBluesky _bluesky({ ); } -FollowAuditRepository _repo(_FakeBluesky client) => FollowAuditRepository(bluesky: client); +FollowAuditRepository _repo(Bluesky client) => FollowAuditRepository(bluesky: client); const _ownerDid = 'did:plc:owner'; @@ -509,18 +614,22 @@ void main() { group('FollowAuditRepository.batchUnfollow', () { test('returns 0 for empty selection (no-op)', () async { - final client = _bluesky(); + final repoService = _FakeRepoService(); + final client = _fakeBluesky( + atproto: _FakeAtProtoClient(repo: repoService), + actor: _FakeActorService(), + ); final repoInstance = _repo(client); final count = await repoInstance.batchUnfollow([], _ownerDid); expect(count, 0); - expect(client.atproto.repo.appliedWrites, isEmpty); + expect(repoService.appliedWrites, isEmpty); }); test('deletes records in a single batch when fewer than 200', () async { final repoService = _FakeRepoService(); - final client = _FakeBluesky( + final client = _fakeBluesky( atproto: _FakeAtProtoClient(repo: repoService), actor: _FakeActorService(), ); @@ -553,7 +662,7 @@ void main() { test('chunks into multiple batches of 200 when > 200 records', () async { final repoService = _FakeRepoService(); - final client = _FakeBluesky( + final client = _fakeBluesky( atproto: _FakeAtProtoClient(repo: repoService), actor: _FakeActorService(), ); @@ -586,7 +695,7 @@ void main() { if (callCount > 1) throw Exception('applyWrites failed'); }, ); - final client = _FakeBluesky( + final client = _fakeBluesky( atproto: _FakeAtProtoClient(repo: repoService), actor: _FakeActorService(), ); diff --git a/test/features/profile/data/profile_context_repository_test.dart b/test/features/profile/data/profile_context_repository_test.dart index 79d3a79..8af70d7 100644 --- a/test/features/profile/data/profile_context_repository_test.dart +++ b/test/features/profile/data/profile_context_repository_test.dart @@ -2,13 +2,19 @@ import 'dart:convert'; import 'package:poptart_core/poptart_core.dart' show AtUri; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/get_profiles.dart'; import 'package:poptart_lex/app/bsky/graph/defs.dart'; +import 'package:poptart_lex/app/bsky/graph/get_list.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/profile/data/profile_context_repository.dart'; +import '../../../helpers/test_bluesky_client.dart'; + ProfileView _buildProfileView(String did, String handle) { return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1)); } @@ -43,12 +49,102 @@ ConstellationClient _constellationWithResponses(Map Function(Ur ); } -class _FakeBluesky { - _FakeBluesky({required this.actor, required this.graph, required this.atproto}); +Bluesky _fakeBluesky({required dynamic actor, required dynamic graph, required dynamic atproto}) { + final transport = _ProfileContextTransport(actor: actor, graph: graph, atproto: atproto); + return testBluesky(did: 'did:plc:actor', handle: 'actor.bsky.social', getClient: transport.get); +} + +class _ProfileContextTransport { + const _ProfileContextTransport({required this.actor, required this.graph, required this.atproto}); final dynamic actor; final dynamic graph; final dynamic atproto; + + Future get(Uri url, {Map? headers}) async { + final query = url.queryParameters; + switch (url.pathSegments.last) { + case 'app.bsky.actor.getProfiles': + final response = await actor.getProfiles( + actors: url.queryParametersAll['actors'] ?? const [], + $service: url.host, + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + ActorGetProfilesOutput( + profiles: response.data.profiles.map(_toDetailedProfile).toList(growable: false), + ).toJson(), + ); + case 'app.bsky.actor.getProfile': + final response = await actor.getProfile(actor: query['actor']!, $service: url.host, $headers: headers); + return jsonResponse(url, 'GET', _toDetailedProfile(response.data).toJson()); + case 'app.bsky.graph.getList': + final response = await graph.getList(list: AtUri.parse(query['list']!), $service: url.host, $headers: headers); + return jsonResponse(url, 'GET', GraphGetListOutput(list: response.data.list, items: const []).toJson()); + case 'com.atproto.repo.listRecords': + final response = await atproto.repo.listRecords( + repo: query['repo']!, + collection: query['collection']!, + limit: int.tryParse(query['limit'] ?? '') ?? 50, + cursor: query['cursor'], + ); + return jsonResponse( + url, + 'GET', + RepoListRecordsOutput( + records: [ + for (var i = 0; i < response.data.records.length; i++) + RepoListRecordsRecord( + uri: AtUri.parse('at://${query['repo']}/${query['collection']}/$i'), + cid: 'cid-$i', + value: _recordValue(response.data.records[i].value), + ), + ], + cursor: response.data.cursor, + ).toJson(), + ); + default: + return unexpectedGetClient(url, headers: headers); + } + } +} + +ProfileViewDetailed _toDetailedProfile(dynamic profile) { + if (profile is ProfileViewDetailed) { + return profile; + } + + if (profile is ProfileView) { + return ProfileViewDetailed( + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + description: profile.description, + avatar: profile.avatar, + associated: profile.associated, + indexedAt: profile.indexedAt, + createdAt: profile.createdAt, + viewer: profile.viewer, + labels: profile.labels, + verification: profile.verification, + status: profile.status, + debug: profile.debug, + ); + } + + throw ArgumentError.value(profile, 'profile', 'Expected ProfileView or ProfileViewDetailed'); +} + +Map _recordValue(Map value) { + if (value.containsKey(r'$type')) { + return value; + } + if (value.containsKey('subject') && value['subject'] is String) { + return {r'$type': 'app.bsky.graph.block', 'createdAt': '2026-01-01T00:00:00.000Z', ...value}; + } + return value; } class _FakeAtProto { @@ -286,12 +382,12 @@ void main() { test('falls back to getProfile for DIDs missing from getProfiles', () async { final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _FakeActorService( profiles: const [], profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, @@ -316,12 +412,12 @@ void main() { test('falls back to per-DID getProfile when batch getProfiles fails', () async { final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _BatchThrowingActorService( profileByActor: {'did:plc:alice': _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')}, ), @@ -347,12 +443,12 @@ void main() { test('uses public bluesky client for batch profile hydration', () async { final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _FakeActorService(profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')]), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), @@ -452,12 +548,12 @@ void main() { test('collects blocked-by DIDs across pages before hydrating', () async { var getDistinctCalls = 0; final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _FakeActorService( profiles: [_buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social')], profileByActor: const {}, @@ -507,7 +603,7 @@ void main() { final constellation = _constellationWithResponses((_) => {'total': 30, 'dids': dids}); final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _BatchTrackingActorService(profiles: profiles, batchSizes: batchSizes), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), @@ -592,12 +688,12 @@ void main() { ); final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _FakeActorService( profiles: resolvedProfiles, errorsByActor: const { @@ -674,7 +770,7 @@ void main() { test('passes cursor to listRecords and returns cursor from response', () async { String? capturedCursor; final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _FakeActorService(profiles: []), graph: _FakeGraphService(), atproto: _FakeAtProto( @@ -698,7 +794,7 @@ void main() { group('getBlockingCount', () { test('counts records across every listRecords page', () async { final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _FakeActorService(profiles: []), graph: _FakeGraphService(), atproto: _FakeAtProto( @@ -751,12 +847,12 @@ void main() { }); final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _FakeActorService(profiles: const []), graph: _ThrowingGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _FakeActorService(profiles: const []), graph: _FakeGraphService(lists: {listUri: listView}), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), @@ -832,7 +928,7 @@ void main() { AtUri? capturedUri; final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _FakeActorService(profiles: []), graph: _UriCapturingGraphService(lists: {listUri: listView}, onGetList: (u) => capturedUri = u), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), @@ -862,7 +958,7 @@ void main() { var getListCalls = 0; final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _FakeActorService(profiles: []), graph: _CountingGraphService(lists: {listUri: listView}, onGetList: () => getListCalls += 1), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), @@ -925,12 +1021,12 @@ void main() { test('trims and deduplicates DIDs before public hydration', () async { final capturedActors = >[]; final repo = ProfileContextRepository( - bluesky: _FakeBluesky( + bluesky: _fakeBluesky( actor: _ThrowingActorService(), graph: _FakeGraphService(), atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), - publicBluesky: _FakeBluesky( + publicBluesky: _fakeBluesky( actor: _BatchTrackingActorService( profiles: [_buildProfileView('did:plc:alice', 'alice.bsky.social')], batchSizes: [], @@ -959,14 +1055,14 @@ void main() { }); } -_FakeBluesky _buildBluesky({ +Bluesky _buildBluesky({ List profiles = const [], Map profileByActor = const {}, Map lists = const {}, List> blockRecords = const [], String? blockRecordsCursor, }) { - return _FakeBluesky( + return _fakeBluesky( actor: _FakeActorService(profiles: profiles, profileByActor: profileByActor), graph: _FakeGraphService(lists: lists), atproto: _FakeAtProto( diff --git a/test/features/profile/data/profile_repository_actor_likes_test.dart b/test/features/profile/data/profile_repository_actor_likes_test.dart index 47a6652..3383a9b 100644 --- a/test/features/profile/data/profile_repository_actor_likes_test.dart +++ b/test/features/profile/data/profile_repository_actor_likes_test.dart @@ -1,12 +1,19 @@ import 'package:poptart_core/poptart_core.dart'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; +import 'package:poptart_lex/app/bsky/feed/get_actor_likes.dart'; +import 'package:poptart_lex/app/bsky/feed/get_posts.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/profile/data/profile_repository.dart'; +import '../../../helpers/test_bluesky_client.dart'; + void main() { late AppDatabase database; @@ -29,7 +36,7 @@ void main() { hydratedPosts: const [], ); final repoService = _FakeRepoService(recordsData: const _FakeListRecordsData(records: [])); - final bluesky = _FakeBlueskyClient( + final bluesky = _testBlueskyClient( session: const _FakeSession('did:plc:me', 'me.bsky.social'), feed: feedService, repo: repoService, @@ -57,13 +64,15 @@ void main() { records: [ _FakeRepoRecord( value: { - 'subject': {'uri': firstSubject}, + r'$type': 'app.bsky.feed.like', + 'subject': {'uri': firstSubject, 'cid': 'cid-first'}, 'createdAt': '2026-05-02T01:34:47.734Z', }, ), _FakeRepoRecord( value: { - 'subject': {'uri': secondSubject}, + r'$type': 'app.bsky.feed.like', + 'subject': {'uri': secondSubject, 'cid': 'cid-second'}, 'createdAt': '2026-05-02T01:00:00.000Z', }, ), @@ -73,7 +82,7 @@ void main() { final actorRepoResolver = _FakeActorRepositoryServiceResolver( const ActorRepositoryServiceResolution(actor: actorDid, did: actorDid, pdsHost: 'friend.host'), ); - final bluesky = _FakeBlueskyClient( + final bluesky = _testBlueskyClient( session: const _FakeSession('did:plc:me', 'me.bsky.social'), feed: feedService, repo: repoService, @@ -114,13 +123,13 @@ PostView _makePostView(String uri) { ); } -class _FakeBlueskyClient { - _FakeBlueskyClient({required this.session, required this.feed, required _FakeRepoService repo}) - : atproto = _FakeAtprotoClient(repo: repo); - - final _FakeSession session; - final _FakeFeedService feed; - final _FakeAtprotoClient atproto; +Bluesky _testBlueskyClient({ + required _FakeSession session, + required _FakeFeedService feed, + required _FakeRepoService repo, +}) { + final transport = _FakeActorLikesTransport(feed: feed, repo: repo); + return testBluesky(did: session.did, handle: session.handle, getClient: transport.get); } class _FakeSession { @@ -130,12 +139,6 @@ class _FakeSession { final String handle; } -class _FakeAtprotoClient { - const _FakeAtprotoClient({required this.repo}); - - final _FakeRepoService repo; -} - class _FakeFeedService { _FakeFeedService({required _FakeActorLikesData actorLikesPage, required List hydratedPosts}) : _actorLikesPage = actorLikesPage, @@ -169,6 +172,64 @@ class _FakeFeedService { } } +class _FakeActorLikesTransport { + const _FakeActorLikesTransport({required this.feed, required this.repo}); + + final _FakeFeedService feed; + final _FakeRepoService repo; + + Future get(Uri url, {Map? headers}) async { + final query = url.queryParameters; + switch (url.pathSegments.last) { + case 'app.bsky.feed.getActorLikes': + final response = await feed.getActorLikes( + actor: query['actor']!, + cursor: query['cursor'], + limit: int.tryParse(query['limit'] ?? ''), + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + FeedGetActorLikesOutput(feed: response.data.feed, cursor: response.data.cursor).toJson(), + ); + case 'app.bsky.feed.getPosts': + final response = await feed.getPosts( + uris: (url.queryParametersAll['uris'] ?? const []).map(AtUri.parse).toList(growable: false), + $service: url.host, + $headers: headers, + ); + return jsonResponse(url, 'GET', FeedGetPostsOutput(posts: response.data.posts).toJson()); + case 'com.atproto.repo.listRecords': + final response = await repo.listRecords( + repo: query['repo']!, + collection: query['collection']!, + limit: int.tryParse(query['limit'] ?? ''), + cursor: query['cursor'], + reverse: query['reverse'] == 'true', + $service: url.host, + ); + return jsonResponse( + url, + 'GET', + RepoListRecordsOutput( + records: [ + for (var i = 0; i < response.data.records.length; i++) + RepoListRecordsRecord( + uri: AtUri.parse('at://${query['repo']}/${query['collection']}/$i'), + cid: 'cid-$i', + value: response.data.records[i].value, + ), + ], + cursor: response.data.cursor, + ).toJson(), + ); + default: + return unexpectedGetClient(url, headers: headers); + } + } +} + class _FakeRepoService { _FakeRepoService({required _FakeListRecordsData recordsData}) : _recordsData = recordsData; diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index bc44523..ce5e1a9 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -3,12 +3,23 @@ import 'dart:typed_data'; import 'package:poptart_core/poptart_core.dart' as atp_core; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/get_profiles.dart'; +import 'package:poptart_lex/app/bsky/graph/get_followers.dart'; +import 'package:poptart_lex/app/bsky/graph/get_follows.dart'; +import 'package:poptart_lex/app/bsky/graph/get_suggested_follows_by_actor.dart'; +import 'package:poptart_lex/com/atproto/repo/get_record.dart'; +import 'package:poptart_lex/com/atproto/repo/put_record.dart'; +import 'package:poptart_lex/com/atproto/repo/upload_blob.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; +import '../../../helpers/test_bluesky_client.dart'; + void main() { late AppDatabase database; @@ -29,7 +40,7 @@ void main() { ]; final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), graph: _FakeGraphService(suggestions: suggestions), ), @@ -44,7 +55,7 @@ void main() { test('returns empty list when no suggestions', () async { final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), graph: _FakeGraphService(suggestions: []), ), @@ -58,7 +69,7 @@ void main() { test('propagates exceptions from graph service', () async { final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), graph: _FakeGraphService(onGetSuggested: (_) async => throw Exception('network error')), ), @@ -77,7 +88,7 @@ void main() { ]; final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), graph: _FakeGraphService(follows: follows, followsSubject: subject, followsCursor: 'next'), ), @@ -95,7 +106,7 @@ void main() { const followers = [ProfileView(did: 'did:plc:dana', handle: 'dana.bsky.social')]; final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), graph: _FakeGraphService(followers: followers, followersSubject: subject), ), @@ -113,7 +124,7 @@ void main() { final profile = _buildProfile(); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), + bluesky: _testBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), ); final result = await repository.getProfile(profile.did); @@ -128,12 +139,10 @@ void main() { test('refreshes and retries getProfile after unauthorized response', () async { final profile = _buildProfile(); - final initialClient = _FakeBlueskyClient( - actor: _FakeActorService(onGetProfile: (_) async => throw _unauthorizedException()), - ); - final refreshedClient = _FakeBlueskyClient( - actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile)), - ); + final initialActor = _FakeActorService(onGetProfile: (_) async => throw _unauthorizedException()); + final refreshedActor = _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile)); + final initialClient = _testBlueskyClient(actor: initialActor); + final refreshedClient = _testBlueskyClient(actor: refreshedActor); var recoveryCalls = 0; final repository = ProfileRepository( @@ -155,8 +164,8 @@ void main() { expect(result.did, profile.did); expect(recoveryCalls, 1); - expect(initialClient.actor.getProfileCalls, 1); - expect(refreshedClient.actor.getProfileCalls, 1); + expect(initialActor.getProfileCalls, 1); + expect(refreshedActor.getProfileCalls, 1); }); test('falls back to the cached profile when the xrpc request fails', () async { @@ -165,7 +174,7 @@ void main() { final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw Exception('request failed')), ), ); @@ -181,7 +190,7 @@ void main() { final profile = _buildProfile(); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), + bluesky: _testBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), ); await database.close(); @@ -196,13 +205,15 @@ void main() { final actors = List.generate(26, (index) => 'did:plc:actor$index'); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService( onGetProfile: (_) async => throw UnimplementedError(), onGetProfiles: (batch) async { requestedBatches.add(List.from(batch)); final profiles = batch - .map((did) => ProfileView(did: did, handle: '$did.bsky.social', indexedAt: DateTime.utc(2026))) + .map( + (did) => ProfileViewDetailed(did: did, handle: '$did.bsky.social', indexedAt: DateTime.utc(2026)), + ) .toList(growable: false); return _FakeProfilesResponse(_FakeProfilesData(profiles)); }, @@ -227,12 +238,13 @@ void main() { 'description': 'Old description', 'labels': {r'$type': 'com.atproto.label.defs#selfLabels', 'values': []}, 'createdAt': '2026-01-01T00:00:00.000Z', + 'futureProfileField': {'enabled': true}, }, cid: 'bafy-current', ); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), atproto: _FakeAtprotoService(repo: repo), ), @@ -261,6 +273,8 @@ void main() { expect(put.record['website'], 'https://alice.example'); expect(put.record['labels'], isA()); expect(put.record['createdAt'], '2026-01-01T00:00:00.000Z'); + expect(put.record['futureProfileField'], {'enabled': true}); + expect(put.record.containsKey(r'$unknown'), isFalse); }); test('removes emptied optional text fields and uploads selected profile images', () async { @@ -282,7 +296,7 @@ void main() { ); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), atproto: _FakeAtprotoService(repo: repo), ), @@ -316,7 +330,7 @@ void main() { final repo = _FakeRepoService(record: const {r'$type': 'app.bsky.actor.profile'}); final repository = ProfileRepository( database: database, - bluesky: _FakeBlueskyClient( + bluesky: _testBlueskyClient( actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), atproto: _FakeAtprotoService(repo: repo), ), @@ -347,14 +361,128 @@ ProfileViewDetailed _buildProfile() { ); } -class _FakeBlueskyClient { - _FakeBlueskyClient({required this.actor, _FakeGraphService? graph, _FakeAtprotoService? atproto}) +Bluesky _testBlueskyClient({required _FakeActorService actor, _FakeGraphService? graph, _FakeAtprotoService? atproto}) { + final transport = _FakeProfileTransport( + actor: actor, + graph: graph ?? _FakeGraphService(), + atproto: atproto ?? _FakeAtprotoService(repo: _FakeRepoService(record: const {})), + ); + return testBluesky(getClient: transport.get, postClient: transport.post); +} + +class _FakeProfileTransport { + _FakeProfileTransport({required this.actor, _FakeGraphService? graph, _FakeAtprotoService? atproto}) : graph = graph ?? _FakeGraphService(), atproto = atproto ?? _FakeAtprotoService(repo: _FakeRepoService(record: const {})); final _FakeActorService actor; final _FakeGraphService graph; final _FakeAtprotoService atproto; + + Future get(Uri url, {Map? headers}) async { + final query = url.queryParameters; + + switch (url.pathSegments.last) { + case 'app.bsky.actor.getProfile': + final response = await actor.getProfile(actor: query['actor']!, $headers: headers); + return jsonResponse(url, 'GET', response.data.toJson()); + case 'app.bsky.actor.getProfiles': + final response = await actor.getProfiles( + actors: url.queryParametersAll['actors'] ?? const [], + $headers: headers, + ); + return jsonResponse(url, 'GET', ActorGetProfilesOutput(profiles: response.data.profiles).toJson()); + case 'app.bsky.graph.getSuggestedFollowsByActor': + final response = await graph.getSuggestedFollowsByActor(actor: query['actor']!, $headers: headers); + return jsonResponse( + url, + 'GET', + GraphGetSuggestedFollowsByActorOutput(suggestions: response.data.suggestions).toJson(), + ); + case 'app.bsky.graph.getFollows': + final response = await graph.getFollows( + actor: query['actor']!, + cursor: query['cursor'], + limit: int.tryParse(query['limit'] ?? ''), + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + GraphGetFollowsOutput( + subject: response.data.subject, + follows: response.data.follows, + cursor: response.data.cursor, + ).toJson(), + ); + case 'app.bsky.graph.getFollowers': + final response = await graph.getFollowers( + actor: query['actor']!, + cursor: query['cursor'], + limit: int.tryParse(query['limit'] ?? ''), + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + GraphGetFollowersOutput( + subject: response.data.subject, + followers: response.data.followers, + cursor: response.data.cursor, + ).toJson(), + ); + case 'com.atproto.repo.getRecord': + final response = await atproto.repo.getRecord( + repo: query['repo']!, + collection: query['collection']!, + rkey: query['rkey']!, + cid: query['cid'], + $headers: headers, + ); + return jsonResponse( + url, + 'GET', + RepoGetRecordOutput( + uri: atp_core.AtUri.parse('at://${query['repo']}/${query['collection']}/${query['rkey']}'), + cid: response.data.cid, + value: response.data.value, + ).toJson(), + ); + default: + return unexpectedGetClient(url, headers: headers); + } + } + + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + switch (url.pathSegments.last) { + case 'com.atproto.repo.putRecord': + final input = RepoPutRecordInput.fromJson((jsonDecode(body as String) as Map).cast()); + await atproto.repo.putRecord( + repo: input.repo, + collection: input.collection, + rkey: input.rkey, + validate: input.validate, + record: input.record, + swapRecord: input.swapRecord, + swapCommit: input.swapCommit, + $headers: headers, + ); + return jsonResponse( + url, + 'POST', + RepoPutRecordOutput( + uri: atp_core.AtUri.parse('at://${input.repo}/${input.collection}/${input.rkey}'), + cid: 'cid-put', + ).toJson(), + ); + case 'com.atproto.repo.uploadBlob': + final bytes = Uint8List.fromList((body as List?) ?? const []); + final response = await atproto.repo.uploadBlob(bytes: bytes, $headers: headers); + return jsonResponse(url, 'POST', RepoUploadBlobOutput(blob: response.data.blob).toJson()); + default: + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + } } class _FakeActorService { @@ -393,7 +521,7 @@ class _FakeProfilesResponse { class _FakeProfilesData { const _FakeProfilesData(this.profiles); - final List profiles; + final List profiles; } class _FakeAtprotoService { diff --git a/test/features/search/data/search_repository_fallback_test.dart b/test/features/search/data/search_repository_fallback_test.dart index 55aa258..6ca8237 100644 --- a/test/features/search/data/search_repository_fallback_test.dart +++ b/test/features/search/data/search_repository_fallback_test.dart @@ -2,15 +2,16 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; -class _StubBluesky {} +import '../../../helpers/test_bluesky_client.dart'; void main() { - late dynamic bluesky; + late Bluesky bluesky; setUp(() { - bluesky = _StubBluesky(); + bluesky = testBluesky(); }); test('search public reads do not fallback when disabled', () async { diff --git a/test/features/search/data/search_repository_post_filters_test.dart b/test/features/search/data/search_repository_post_filters_test.dart index 1d7ecbe..de6fcfd 100644 --- a/test/features/search/data/search_repository_post_filters_test.dart +++ b/test/features/search/data/search_repository_post_filters_test.dart @@ -3,22 +3,11 @@ import 'package:poptart_lex/app/bsky/actor/defs.dart'; import 'package:poptart_lex/app/bsky/feed/defs.dart'; import 'package:poptart_lex/app/bsky/feed/search_posts.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:lazurite/features/search/data/post_search_filters.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; -class _FakeResponse { - _FakeResponse(this.data); - - final T data; -} - -class _FakeSearchPostsData { - _FakeSearchPostsData({required this.posts, this.cursor, this.hitsTotal}); - - final List posts; - final String? cursor; - final int? hitsTotal; -} +import '../../../helpers/test_bluesky_client.dart'; class _FakeFeedService { String? lastQ; @@ -34,36 +23,29 @@ class _FakeFeedService { String? lastCursor; int? lastLimit; - Future<_FakeResponse<_FakeSearchPostsData>> searchPosts({ - required String q, - FeedSearchPostsSort? sort, - String? since, - String? until, - String? mentions, - String? author, - String? lang, - String? domain, - String? url, - List? tag, - String? cursor, - int? limit, - Map? $headers, - }) async { - lastQ = q; - lastSort = sort; - lastSince = since; - lastUntil = until; - lastMentions = mentions; - lastAuthor = author; - lastLang = lang; - lastDomain = domain; - lastUrl = url; - lastTags = tag; - lastCursor = cursor; - lastLimit = limit; - - return _FakeResponse( - _FakeSearchPostsData( + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.feed.searchPosts') { + return unexpectedGetClient(url, headers: headers); + } + + final query = url.queryParameters; + lastQ = query['q']; + lastSort = FeedSearchPostsSort.valueOf(query['sort']); + lastSince = query['since']; + lastUntil = query['until']; + lastMentions = query['mentions']; + lastAuthor = query['author']; + lastLang = query['lang']; + lastDomain = query['domain']; + lastUrl = query['url']; + lastTags = url.queryParametersAll['tag']; + lastCursor = query['cursor']; + lastLimit = int.tryParse(query['limit'] ?? ''); + + return jsonResponse( + url, + 'GET', + FeedSearchPostsOutput( posts: [ PostView( uri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/1'), @@ -75,17 +57,11 @@ class _FakeFeedService { ], cursor: 'next', hitsTotal: 42, - ), + ).toJson(), ); } } -class _FakeBluesky { - _FakeBluesky(this.feed); - - final _FakeFeedService feed; -} - void main() { group('SearchRepository.searchPosts filter mapping', () { late _FakeFeedService feed; @@ -93,7 +69,7 @@ void main() { setUp(() { feed = _FakeFeedService(); - repository = SearchRepository(bluesky: _FakeBluesky(feed)); + repository = SearchRepository(bluesky: testBluesky(getClient: feed.get)); }); test('maps all filters and sort to SDK call', () async { diff --git a/test/features/typeahead/data/typeahead_repository_test.dart b/test/features/typeahead/data/typeahead_repository_test.dart index 9115d98..32014e8 100644 --- a/test/features/typeahead/data/typeahead_repository_test.dart +++ b/test/features/typeahead/data/typeahead_repository_test.dart @@ -2,13 +2,17 @@ import 'dart:convert'; import 'dart:io'; import 'package:poptart_lex/app/bsky/actor/defs.dart'; +import 'package:poptart_lex/app/bsky/actor/search_actors_typeahead.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../helpers/test_bluesky_client.dart'; + class MockModerationService extends Mock implements ModerationService {} void main() { @@ -41,7 +45,7 @@ void main() { ).thenReturn(true); final repository = TypeaheadRepository( - bluesky: _FakeBlueskyClient(actor: actorService), + bluesky: _fakeBlueskyClient(actor: actorService), provider: TypeaheadRepository.blueskyProvider, moderationService: moderationService, ); @@ -127,7 +131,7 @@ void main() { final client = _CallbackClient((_) async => http.Response('upstream unavailable', 503)); final repository = TypeaheadRepository( - bluesky: _FakeBlueskyClient(actor: actorService), + bluesky: _fakeBlueskyClient(actor: actorService), provider: TypeaheadRepository.communityProvider, moderationService: moderationService, httpClient: client, @@ -161,7 +165,7 @@ void main() { }); final repository = TypeaheadRepository( - bluesky: _FakeBlueskyClient(actor: actorService), + bluesky: _fakeBlueskyClient(actor: actorService), providerResolver: () => selectedProvider, moderationService: moderationService, httpClient: client, @@ -265,11 +269,7 @@ void main() { }); } -class _FakeBlueskyClient { - _FakeBlueskyClient({required this.actor}); - - final _FakeActorService actor; -} +Bluesky _fakeBlueskyClient({required _FakeActorService actor}) => testBluesky(getClient: actor.get); class _FakeActorService { _FakeActorsData? searchActorsResult; @@ -277,15 +277,15 @@ class _FakeActorService { int? lastLimit; Map? lastHeaders; - Future<_FakeResponse<_FakeActorsData>> searchActorsTypeahead({ - required String q, - int? limit, - Map? $headers, - }) async { - lastQuery = q; - lastLimit = limit; - lastHeaders = $headers; - return _FakeResponse(searchActorsResult!); + Future get(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.actor.searchActorsTypeahead') { + return unexpectedGetClient(url, headers: headers); + } + + lastQuery = url.queryParameters['q']; + lastLimit = int.tryParse(url.queryParameters['limit'] ?? ''); + lastHeaders = headers; + return jsonResponse(url, 'GET', ActorSearchActorsTypeaheadOutput(actors: searchActorsResult!.actors).toJson()); } } @@ -295,12 +295,6 @@ class _FakeActorsData { final List actors; } -class _FakeResponse { - _FakeResponse(this.data); - - final T data; -} - class _CallbackClient implements http.Client { _CallbackClient(this._handler); diff --git a/test/helpers/test_bluesky_client.dart b/test/helpers/test_bluesky_client.dart new file mode 100644 index 0000000..81078e9 --- /dev/null +++ b/test/helpers/test_bluesky_client.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; + +Bluesky testBluesky({ + GetClient? getClient, + PostClient? postClient, + String did = 'did:plc:test', + String handle = 'test.bsky.social', + String service = 'bsky.social', +}) { + return Bluesky.fromSession( + Session(did: did, handle: handle, accessJwt: 'access-token', refreshJwt: 'refresh-token'), + service: service, + getClient: getClient ?? unexpectedGetClient, + postClient: postClient ?? unexpectedPostClient, + ); +} + +Future unexpectedGetClient(Uri url, {Map? headers}) async { + throw StateError('Unexpected GET ${url.path}'); +} + +Future unexpectedPostClient( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, +}) async { + throw StateError('Unexpected POST ${url.path}'); +} + +http.Response jsonResponse(Uri url, String method, Map body, {int statusCode = 200}) { + return http.Response( + jsonEncode(body), + statusCode, + headers: {'content-type': 'application/json; charset=utf-8'}, + request: http.Request(method, url), + ); +} diff --git a/test/shared/utils/atproto_datetime_test.dart b/test/shared/utils/atproto_datetime_test.dart new file mode 100644 index 0000000..a3dfb8a --- /dev/null +++ b/test/shared/utils/atproto_datetime_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/utils/atproto_datetime.dart'; + +void main() { + group('ATProto datetime utilities', () { + test('formats UTC datetimes with millisecond precision and Z suffix', () { + final formatted = formatAtProtoDateTime(DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513)); + + expect(formatted, '2026-05-12T10:11:50.052Z'); + }); + + test('formats local datetimes as UTC datetimes', () { + final local = DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal(); + + expect(formatAtProtoDateTime(local), '2026-05-12T10:11:50.052Z'); + }); + + test('returns null for strings that are not datetimes', () { + expect(formatAtProtoDateTimeString('not-a-date'), isNull); + }); + + test('canonicalizes datetimes to UTC with microseconds truncated', () { + final canonical = canonicalAtProtoDateTime(DateTime.utc(2026, 5, 12, 10, 11, 50, 52, 513).toLocal()); + + expect(canonical, DateTime.utc(2026, 5, 12, 10, 11, 50, 52)); + expect(canonical.microsecond, 0); + }); + }); +} -- 2.51.2