diff --git a/lib/app/lazurite_app.dart b/lib/app/lazurite_app.dart index eb21d83..818ee0c 100644 --- a/lib/app/lazurite_app.dart +++ b/lib/app/lazurite_app.dart @@ -12,6 +12,7 @@ import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/logging/logging_navigator_observer.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/objectbox/objectbox_store.dart'; @@ -33,6 +34,7 @@ import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; @@ -561,6 +563,17 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), + RepositoryProvider( + create: (context) => SimilarPostsRepository( + bluesky: bluesky, + constellationClient: ConstellationClient( + baseUrl: context.read().state.constellationUrl, + ), + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), RepositoryProvider( create: (context) => StarterPackRepository( bluesky: bluesky, diff --git a/lib/core/router/content_route_factory.dart b/lib/core/router/content_route_factory.dart index 2bcd664..c552e02 100644 --- a/lib/core/router/content_route_factory.dart +++ b/lib/core/router/content_route_factory.dart @@ -10,6 +10,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; @@ -151,9 +152,16 @@ class ContentRouteFactory { if (providerKey == null) { return PostThreadScreen(postUri: postUri); } - return RepositoryProvider( - key: ValueKey('authenticated-post-thread-repository-$providerKey'), - create: (_) => _authenticatedPostThreadRepository(context, providerKey), + return MultiRepositoryProvider( + key: ValueKey('authenticated-post-thread-repositories-$providerKey'), + providers: [ + RepositoryProvider( + create: (_) => _authenticatedPostThreadRepository(context, providerKey), + ), + RepositoryProvider( + create: (_) => _authenticatedSimilarPostsRepository(context, providerKey), + ), + ], child: PostThreadScreen(postUri: postUri), ); } @@ -354,6 +362,15 @@ class ContentRouteFactory { onUnauthorized: onUnauthorized, ); + SimilarPostsRepository _authenticatedSimilarPostsRepository(BuildContext context, String providerKey) => + SimilarPostsRepository( + bluesky: context.read(), + constellationClient: ConstellationClient(baseUrl: context.read().state.constellationUrl), + moderationService: _moderationServiceOrNull(context), + appViewProvider: providerKey, + onUnauthorized: onUnauthorized, + ); + ModerationService? _moderationServiceOrNull(BuildContext context) { try { return context.read(); diff --git a/lib/features/feed/cubit/similar_posts_cubit.dart b/lib/features/feed/cubit/similar_posts_cubit.dart new file mode 100644 index 0000000..607c9ee --- /dev/null +++ b/lib/features/feed/cubit/similar_posts_cubit.dart @@ -0,0 +1,84 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; + +/// Loading phases for the similar-posts section. +/// +/// The section starts idle so thread rendering never waits on the graph lookup. +/// A user tap moves it to [loading], and pagination uses [loadingMore] while +/// preserving already-rendered posts. +enum SimilarPostsStatus { idle, loading, loaded, loadingMore, error } + +class SimilarPostsState extends Equatable { + const SimilarPostsState({ + this.status = SimilarPostsStatus.idle, + this.posts = const [], + this.cursor, + this.error, + }); + + final SimilarPostsStatus status; + final List posts; + final String? cursor; + final String? error; + + bool get hasMore => cursor != null && cursor!.isNotEmpty; + + @override + List get props => [status, posts, cursor, error]; +} + +/// Coordinates the opt-in similar-posts UI with [SimilarPostsRepository]. +/// +/// The cubit deliberately exposes explicit load methods instead of loading in +/// its constructor. That keeps network work behind the "Show similar posts" UI +/// affordance and avoids adding overhead to every thread open. +class SimilarPostsCubit extends Cubit { + SimilarPostsCubit({required SimilarPostsRepository repository}) + : _repository = repository, + super(const SimilarPostsState()); + + final SimilarPostsRepository _repository; + String? _postUri; + + Future load(String postUri) async { + final normalizedPostUri = postUri.trim(); + if (normalizedPostUri.isEmpty) return; + + _postUri = normalizedPostUri; + emit(const SimilarPostsState(status: SimilarPostsStatus.loading)); + try { + final page = await _repository.getSimilarPosts(postUri: normalizedPostUri); + emit(SimilarPostsState(status: SimilarPostsStatus.loaded, posts: page.posts, cursor: page.cursor)); + } catch (error, stackTrace) { + log.w('Failed to load similar posts for $normalizedPostUri', error: error, stackTrace: stackTrace); + emit(const SimilarPostsState(status: SimilarPostsStatus.error, error: 'Similar posts are unavailable.')); + } + } + + Future loadMore() async { + final postUri = _postUri; + final cursor = state.cursor; + if (postUri == null || cursor == null || cursor.isEmpty || state.status == SimilarPostsStatus.loadingMore) { + return; + } + + final existing = state.posts; + emit(SimilarPostsState(status: SimilarPostsStatus.loadingMore, posts: existing, cursor: cursor)); + try { + final page = await _repository.getSimilarPosts(postUri: postUri, cursor: cursor); + final seen = existing.map((post) => post.uri.toString()).toSet(); + final merged = [ + ...existing, + for (final post in page.posts) + if (seen.add(post.uri.toString())) post, + ]; + emit(SimilarPostsState(status: SimilarPostsStatus.loaded, posts: merged, cursor: page.cursor)); + } catch (error, stackTrace) { + log.w('Failed to load more similar posts for $postUri', error: error, stackTrace: stackTrace); + emit(SimilarPostsState(status: SimilarPostsStatus.loaded, posts: existing, cursor: cursor)); + } + } +} diff --git a/lib/features/feed/data/similar_posts_repository.dart b/lib/features/feed/data/similar_posts_repository.dart new file mode 100644 index 0000000..fcc1e1d --- /dev/null +++ b/lib/features/feed/data/similar_posts_repository.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/foundation.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/constellation_client.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'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + +/// Source key used by Constellation to describe a like record pointing at a +/// post subject URI. +/// +/// The feature intentionally treats Constellation as a relationship index only: +/// it discovers candidate post URIs, while AppView hydration below decides what +/// is safe and current enough to render. +const String similarPostsLikeSource = 'app.bsky.feed.like:subject.uri'; + +/// Returns posts that are related to a seed post through shared public likes. +/// +/// Data flow: +/// 1. Constellation receives the seed post URI and returns other post URIs liked +/// by accounts that also liked the seed. +/// 2. The repository ranks those URIs locally by shared-like count. +/// 3. The top URIs are hydrated with one `app.bsky.feed.getPosts` request. +/// 4. Hydrated posts are filtered through the same moderation service used by +/// feeds before they reach the UI. +/// +/// This keeps the repository/network cost bounded: one graph lookup plus one +/// AppView hydration request per page, with a short in-memory cache for repeat +/// thread opens. +class SimilarPostsRepository { + SimilarPostsRepository({ + required Bluesky bluesky, + required ConstellationClient constellationClient, + ModerationService? moderationService, + String? appViewProvider, + String Function()? appViewProviderResolver, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + DateTime Function()? now, + Duration cacheTtl = const Duration(hours: 1), + @visibleForTesting + Future<({List items, String? cursor})> Function(String postUri, String? cursor, int limit)? + relationshipLoader, + @visibleForTesting Future> Function(List uris)? postHydrator, + }) : _constellationClient = constellationClient, + _moderationService = moderationService, + _appViewContext = AppViewRequestContext( + appViewProvider: appViewProvider, + appViewProviderResolver: appViewProviderResolver, + ), + _now = now ?? DateTime.now, + _cacheTtl = cacheTtl, + _relationshipLoaderForTest = relationshipLoader, + _postHydratorForTest = postHydrator { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + onUnauthorizedException: (error, stackTrace) { + log.w('similar_posts.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); + }, + ); + } + + late final UnauthorizedRecoveryRunner _authRecovery; + final ConstellationClient _constellationClient; + final ModerationService? _moderationService; + final AppViewRequestContext _appViewContext; + final DateTime Function() _now; + final Duration _cacheTtl; + final Future<({List items, String? cursor})> Function(String postUri, String? cursor, int limit)? + _relationshipLoaderForTest; + final Future> Function(List uris)? _postHydratorForTest; + + final Map _cache = {}; + + static const int defaultRelationshipLimit = 100; + static const int defaultHydrationLimit = 10; + static const int _maxHydrationBatchSize = 25; + + /// Loads one page of posts similar to [postUri]. + /// + /// [cursor] is the Constellation cursor from a previous call. The returned + /// cursor should be passed back unchanged when the UI asks for more. Cached + /// pages are keyed by post URI and cursor so a thread reopen does not repeat + /// the graph/hydration work inside [_cacheTtl]. + Future getSimilarPosts({ + required String postUri, + String? cursor, + int relationshipLimit = defaultRelationshipLimit, + int hydrationLimit = defaultHydrationLimit, + }) async { + final normalizedPostUri = postUri.trim(); + if (normalizedPostUri.isEmpty || relationshipLimit <= 0 || hydrationLimit <= 0) { + return const SimilarPostsPage(posts: []); + } + + final cacheKey = _cacheKey(normalizedPostUri, cursor, relationshipLimit, hydrationLimit); + final cached = _cache[cacheKey]; + if (cached != null && _now().difference(cached.storedAt) < _cacheTtl) { + return cached.page; + } + + final relationships = await _loadRelationships(normalizedPostUri, cursor, relationshipLimit); + final rankedUris = _rankCandidateUris(normalizedPostUri, relationships.items); + if (rankedUris.isEmpty) { + final empty = SimilarPostsPage(posts: const [], cursor: relationships.cursor); + _cache[cacheKey] = _CachedSimilarPostsPage(empty, _now()); + return empty; + } + + final hydrated = await _hydrateRankedPosts(rankedUris.take(hydrationLimit).toList(growable: false)); + final page = SimilarPostsPage(posts: hydrated, cursor: relationships.cursor); + _cache[cacheKey] = _CachedSimilarPostsPage(page, _now()); + return page; + } + + Future<({List items, String? cursor})> _loadRelationships(String postUri, String? cursor, int limit) { + final loader = _relationshipLoaderForTest; + if (loader != null) { + return loader(postUri, cursor, limit); + } + return _constellationClient.getManyToMany( + postUri, + similarPostsLikeSource, + 'subject.uri', + limit: limit, + cursor: cursor, + ); + } + + /// Counts duplicate candidate URIs from Constellation. A duplicate means more + /// than one account liked both the seed post and the candidate post, which is + /// the MVP definition of stronger similarity. + List _rankCandidateUris(String seedPostUri, List items) { + final counts = {}; + for (final item in items) { + final candidateUri = item.otherSubject.trim(); + if (candidateUri.isEmpty || candidateUri == seedPostUri || !_isPostUri(candidateUri)) { + continue; + } + counts[candidateUri] = (counts[candidateUri] ?? 0) + 1; + } + + final ranked = counts.entries.toList() + ..sort((a, b) { + final bySharedLikes = b.value.compareTo(a.value); + if (bySharedLikes != 0) return bySharedLikes; + return a.key.compareTo(b.key); + }); + return ranked.map((entry) => entry.key).toList(growable: false); + } + + bool _isPostUri(String value) { + try { + final uri = atcore.AtUri.parse(value); + return uri.collection.toString() == 'app.bsky.feed.post' && uri.rkey.isNotEmpty; + } catch (error, stackTrace) { + log.d('similar_posts skipping invalid candidate URI: $value', error: error, stackTrace: stackTrace); + return false; + } + } + + /// Hydrates ranked URI strings and restores the ranking after AppView returns + /// whatever posts are still available to this viewer. Missing/deleted/blocked + /// posts are naturally dropped because they are absent from the response. + Future> _hydrateRankedPosts(List rankedUris) async { + final uris = []; + for (final value in rankedUris.take(_maxHydrationBatchSize)) { + try { + uris.add(atcore.AtUri.parse(value)); + } catch (error, stackTrace) { + log.d('similar_posts failed to parse ranked URI: $value', error: error, stackTrace: stackTrace); + } + } + if (uris.isEmpty) return const []; + + final hydrator = _postHydratorForTest; + final posts = hydrator == null ? await _hydratePostsFromAppView(uris) : await hydrator(uris); + final postsByUri = {for (final post in posts) post.uri.toString(): post}; + final moderated = []; + for (final uri in rankedUris) { + final post = postsByUri[uri]; + if (post == null) continue; + if (_moderationService?.shouldFilterPostInList(post) ?? false) continue; + moderated.add(post); + } + return moderated; + } + + Future> _hydratePostsFromAppView(List uris) async { + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getPosts', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run((client) => client.feed.getPosts(uris: uris, $headers: headers)); + return response.data.posts; + } + + String _cacheKey(String postUri, String? cursor, int relationshipLimit, int hydrationLimit) => + '$postUri|${cursor ?? ''}|$relationshipLimit|$hydrationLimit'; +} + +class SimilarPostsPage { + const SimilarPostsPage({required this.posts, this.cursor}); + + final List posts; + final String? cursor; + + bool get hasMore => cursor != null && cursor!.isNotEmpty; +} + +class _CachedSimilarPostsPage { + const _CachedSimilarPostsPage(this.page, this.storedAt); + + final SimilarPostsPage page; + final DateTime storedAt; +} diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index cb8db87..881b15b 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; @@ -16,8 +17,10 @@ import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/cubit/similar_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; @@ -25,6 +28,7 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actio import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_menu_actions.dart'; import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; +import 'package:lazurite/features/feed/presentation/widgets/similar_posts_section.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; @@ -45,11 +49,38 @@ class PostThreadScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => PostThreadCubit(postThreadRepository: context.read())..load(postUri), - child: _PostThreadContent(postUri: postUri, publicProviderKey: publicProviderKey), + final similarPostsRepository = publicProviderKey == null ? _similarPostsRepositoryOrNull(context) : null; + final content = _PostThreadContent( + postUri: postUri, + publicProviderKey: publicProviderKey, + showSimilarPosts: similarPostsRepository != null, + ); + if (similarPostsRepository == null) { + return BlocProvider( + create: (_) => PostThreadCubit(postThreadRepository: context.read())..load(postUri), + child: content, + ); + } + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => PostThreadCubit(postThreadRepository: context.read())..load(postUri), + ), + BlocProvider(create: (_) => SimilarPostsCubit(repository: similarPostsRepository)), + ], + child: content, ); } + + SimilarPostsRepository? _similarPostsRepositoryOrNull(BuildContext context) { + try { + return context.read(); + } catch (error, stackTrace) { + log.d('SimilarPostsRepository not found; hiding similar posts section', error: error, stackTrace: stackTrace); + return null; + } + } } const int _maxThreadDepth = 3; @@ -81,9 +112,10 @@ Set computeInitialCollapsedThreadUris(ThreadViewPost thread, {required i } class _PostThreadContent extends StatefulWidget { - const _PostThreadContent({required this.postUri, this.publicProviderKey}); + const _PostThreadContent({required this.postUri, required this.showSimilarPosts, this.publicProviderKey}); final String postUri; + final bool showSimilarPosts; final String? publicProviderKey; @override @@ -257,6 +289,7 @@ class _PostThreadContentState extends State<_PostThreadContent> { feedViewPost: FeedViewPost(post: thread.post), providerKey: publicProviderKey, ), + if (widget.showSimilarPosts) SimilarPostsSection(postUri: thread.post.uri.toString()), if (replies.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), diff --git a/lib/features/feed/presentation/widgets/similar_posts_section.dart b/lib/features/feed/presentation/widgets/similar_posts_section.dart new file mode 100644 index 0000000..2adc9fe --- /dev/null +++ b/lib/features/feed/presentation/widgets/similar_posts_section.dart @@ -0,0 +1,130 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/feed/cubit/similar_posts_cubit.dart'; +import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; +import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; + +/// Opt-in section that shows posts related through shared public likes. +/// +/// The section is intentionally collapsed until the user asks for it. That makes +/// the data flow visible to the user and prevents every thread open from paying +/// the Constellation/AppView cost. Once expanded, [SimilarPostsCubit] owns the +/// network flow and this widget only renders states. +class SimilarPostsSection extends StatelessWidget { + const SimilarPostsSection({super.key, required this.postUri}); + + final String postUri; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.status == SimilarPostsStatus.idle) { + return _SimilarPostsShell( + child: Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => context.read().load(postUri), + icon: const Icon(Icons.auto_awesome_outlined), + label: const Text('Show similar posts'), + ), + ), + ); + } + + if (state.status == SimilarPostsStatus.loading) { + return const _SimilarPostsShell(child: LinearProgressIndicator()); + } + + if (state.status == SimilarPostsStatus.error) { + return _SimilarPostsShell( + child: Row( + children: [ + Expanded(child: Text(state.error ?? 'Similar posts are unavailable.')), + TextButton( + onPressed: () => context.read().load(postUri), + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (state.posts.isEmpty) { + return const _SimilarPostsShell(child: Text('No similar posts found yet.')); + } + + return _SimilarPostsShell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth.clamp(260.0, 360.0).toDouble(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: state.posts + .map( + (post) => Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + width: cardWidth, + child: CompactPostCard( + feedViewPost: FeedViewPost(post: post), + onTap: () => navigateToPost(context, post.uri.toString()), + ), + ), + ), + ) + .toList(growable: false), + ), + ); + }, + ), + if (state.hasMore) + TextButton.icon( + onPressed: state.status == SimilarPostsStatus.loadingMore + ? null + : () => context.read().loadMore(), + icon: state.status == SimilarPostsStatus.loadingMore + ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.expand_more), + label: const Text('Show more'), + ), + ], + ), + ); + }, + ); + } +} + +class _SimilarPostsShell extends StatelessWidget { + const _SimilarPostsShell({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Similar posts', style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text( + 'Liked by people who liked this post', + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } +} diff --git a/test/features/feed/cubit/similar_posts_cubit_test.dart b/test/features/feed/cubit/similar_posts_cubit_test.dart new file mode 100644 index 0000000..346daa5 --- /dev/null +++ b/test/features/feed/cubit/similar_posts_cubit_test.dart @@ -0,0 +1,95 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/features/feed/cubit/similar_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBluesky extends Mock implements Bluesky {} + +void main() { + group('SimilarPostsCubit', () { + blocTest( + 'loads the first page on demand', + build: () => SimilarPostsCubit( + repository: _repository(items: [_item('at://did:plc:one/app.bsky.feed.post/a')], cursor: 'next'), + ), + act: (cubit) => cubit.load('at://did:plc:seed/app.bsky.feed.post/root'), + expect: () => [ + const SimilarPostsState(status: SimilarPostsStatus.loading), + isA() + .having((state) => state.status, 'status', SimilarPostsStatus.loaded) + .having((state) => state.posts.length, 'posts length', 1) + .having((state) => state.cursor, 'cursor', 'next'), + ], + ); + + blocTest( + 'merges loadMore results without duplicates', + build: () { + var call = 0; + return SimilarPostsCubit( + repository: _repositoryWithLoader((_, cursor, _) async { + call++; + if (call == 1) { + return (items: [_item('at://did:plc:one/app.bsky.feed.post/a')], cursor: 'next'); + } + expect(cursor, 'next'); + return ( + items: [_item('at://did:plc:one/app.bsky.feed.post/a'), _item('at://did:plc:two/app.bsky.feed.post/b')], + cursor: null, + ); + }), + ); + }, + act: (cubit) async { + await cubit.load('at://did:plc:seed/app.bsky.feed.post/root'); + await cubit.loadMore(); + }, + expect: () => [ + const SimilarPostsState(status: SimilarPostsStatus.loading), + isA().having((state) => state.posts.length, 'posts length', 1), + isA().having((state) => state.status, 'status', SimilarPostsStatus.loadingMore), + isA().having((state) => state.status, 'status', SimilarPostsStatus.loaded).having( + (state) => state.posts.map((post) => post.uri.toString()), + 'uris', + ['at://did:plc:one/app.bsky.feed.post/a', 'at://did:plc:two/app.bsky.feed.post/b'], + ), + ], + ); + }); +} + +SimilarPostsRepository _repository({required List items, String? cursor}) => + _repositoryWithLoader((_, _, _) async => (items: items, cursor: cursor)); + +SimilarPostsRepository _repositoryWithLoader( + Future<({List items, String? cursor})> Function(String postUri, String? cursor, int limit) loader, +) { + return SimilarPostsRepository( + bluesky: MockBluesky(), + constellationClient: ConstellationClient(), + relationshipLoader: loader, + postHydrator: (uris) async => uris.map((uri) => _post(uri.toString())).toList(), + ); +} + +ManyToManyItem _item(String otherSubject) => ManyToManyItem( + linkRecord: const ConstellationLinkRecord(did: 'did:plc:liker', collection: 'app.bsky.feed.like', rkey: 'like'), + otherSubject: otherSubject, +); + +PostView _post(String uri) => PostView( + uri: AtUri(uri), + cid: 'cid-$uri', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.example'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'similar post', + 'createdAt': DateTime.utc(2026, 5, 23).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 5, 23), +); diff --git a/test/features/feed/data/similar_posts_repository_test.dart b/test/features/feed/data/similar_posts_repository_test.dart new file mode 100644 index 0000000..10c87ed --- /dev/null +++ b/test/features/feed/data/similar_posts_repository_test.dart @@ -0,0 +1,115 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBluesky extends Mock implements Bluesky {} + +void main() { + group('SimilarPostsRepository', () { + test('loads relationships, ranks shared-like duplicates, and hydrates only the top candidates', () async { + final hydratedUris = >[]; + final repository = SimilarPostsRepository( + bluesky: MockBluesky(), + constellationClient: ConstellationClient(), + relationshipLoader: (postUri, cursor, limit) async { + expect(postUri, 'at://did:plc:seed/app.bsky.feed.post/root'); + expect(cursor, isNull); + expect(limit, 100); + return ( + items: [ + _item('at://did:plc:one/app.bsky.feed.post/a'), + _item('at://did:plc:two/app.bsky.feed.post/b'), + _item('at://did:plc:one/app.bsky.feed.post/a'), + _item('at://did:plc:seed/app.bsky.feed.post/root'), + _item('not-a-uri'), + ], + cursor: 'next', + ); + }, + postHydrator: (uris) async { + hydratedUris.add(uris.map((uri) => uri.toString()).toList()); + return [_post('at://did:plc:two/app.bsky.feed.post/b'), _post('at://did:plc:one/app.bsky.feed.post/a')]; + }, + ); + + final page = await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); + + expect(page.cursor, 'next'); + expect(hydratedUris.single, ['at://did:plc:one/app.bsky.feed.post/a', 'at://did:plc:two/app.bsky.feed.post/b']); + expect(page.posts.map((post) => post.uri.toString()), [ + 'at://did:plc:one/app.bsky.feed.post/a', + 'at://did:plc:two/app.bsky.feed.post/b', + ]); + }); + + test('returns an empty page without hydrating when candidates are all invalid or self', () async { + var hydrateCalled = false; + final repository = SimilarPostsRepository( + bluesky: MockBluesky(), + constellationClient: ConstellationClient(), + relationshipLoader: (_, _, _) async => ( + items: [ + _item(''), + _item('not-a-uri'), + _item('at://did:plc:seed/app.bsky.feed.post/root'), + _item('at://did:plc:actor/app.bsky.feed.like/rkey'), + ], + cursor: null, + ), + postHydrator: (_) async { + hydrateCalled = true; + return const []; + }, + ); + + final page = await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); + + expect(page.posts, isEmpty); + expect(hydrateCalled, isFalse); + }); + + test('caches pages within the configured ttl', () async { + var now = DateTime.utc(2026, 5, 23, 12); + var relationshipCalls = 0; + final repository = SimilarPostsRepository( + bluesky: MockBluesky(), + constellationClient: ConstellationClient(), + now: () => now, + cacheTtl: const Duration(hours: 1), + relationshipLoader: (_, _, _) async { + relationshipCalls++; + return (items: [_item('at://did:plc:one/app.bsky.feed.post/a')], cursor: null); + }, + postHydrator: (_) async => [_post('at://did:plc:one/app.bsky.feed.post/a')], + ); + + await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); + await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); + now = now.add(const Duration(hours: 2)); + await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); + + expect(relationshipCalls, 2); + }); + }); +} + +ManyToManyItem _item(String otherSubject) => ManyToManyItem( + linkRecord: const ConstellationLinkRecord(did: 'did:plc:liker', collection: 'app.bsky.feed.like', rkey: 'like'), + otherSubject: otherSubject, +); + +PostView _post(String uri) => PostView( + uri: AtUri(uri), + cid: 'cid-$uri', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.example'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'similar post', + 'createdAt': DateTime.utc(2026, 5, 23).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 5, 23), +); diff --git a/test/features/feed/presentation/similar_posts_section_test.dart b/test/features/feed/presentation/similar_posts_section_test.dart new file mode 100644 index 0000000..7402149 --- /dev/null +++ b/test/features/feed/presentation/similar_posts_section_test.dart @@ -0,0 +1,72 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/features/feed/cubit/similar_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/similar_posts_repository.dart'; +import 'package:lazurite/features/feed/presentation/widgets/similar_posts_section.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBluesky extends Mock implements Bluesky {} + +void main() { + testWidgets('keeps similar posts collapsed until requested', (tester) async { + await tester.pumpWidget(_TestApp(repository: _repository(posts: [_post('at://did:plc:one/app.bsky.feed.post/a')]))); + + expect(find.text('Similar posts'), findsOneWidget); + expect(find.text('Liked by people who liked this post'), findsOneWidget); + expect(find.text('Show similar posts'), findsOneWidget); + expect(find.text('author.example'), findsNothing); + + await tester.tap(find.text('Show similar posts')); + await tester.pumpAndSettle(); + + expect(find.text('author.example'), findsOneWidget); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.repository}); + + final SimilarPostsRepository repository; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: BlocProvider( + create: (_) => SimilarPostsCubit(repository: repository), + child: const SimilarPostsSection(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'), + ), + ), + ); + } +} + +SimilarPostsRepository _repository({required List posts}) => SimilarPostsRepository( + bluesky: MockBluesky(), + constellationClient: ConstellationClient(), + relationshipLoader: (_, _, _) async => + (items: posts.map((post) => _item(post.uri.toString())).toList(), cursor: null), + postHydrator: (_) async => posts, +); + +ManyToManyItem _item(String otherSubject) => ManyToManyItem( + linkRecord: const ConstellationLinkRecord(did: 'did:plc:liker', collection: 'app.bsky.feed.like', rkey: 'like'), + otherSubject: otherSubject, +); + +PostView _post(String uri) => PostView( + uri: AtUri(uri), + cid: 'cid-$uri', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.example'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'similar post body', + 'createdAt': DateTime.utc(2026, 5, 23).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 5, 23), +);