diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart index d5a0cbe..ce54a9f 100644 --- a/lib/core/l10n/app_localizations.dart +++ b/lib/core/l10n/app_localizations.dart @@ -1762,7 +1762,7 @@ abstract class AppLocalizations { /// Settings subtitle for semantic search management /// /// In en, this message translates to: - /// **'Manage semantic search from Bookmarks & Likes -> Search'** + /// **'Manage searching your bookmarks & saved posts.'** String get messageManageSemanticSearchSubtitle; /// Settings subtitle for opening app logs diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index 75cbe9e..79a41cb 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint diff --git a/lib/core/network/constellation_client.dart b/lib/core/network/constellation_client.dart index ab14f5e..1f29768 100644 --- a/lib/core/network/constellation_client.dart +++ b/lib/core/network/constellation_client.dart @@ -112,7 +112,7 @@ class ConstellationClient { int? limit, String? cursor, }) async { - final uri = _xrpcUri('blue.microcosm.links.getDistinct', { + final uri = _xrpcUri('blue.microcosm.links.getBacklinkDids', { 'subject': subject, 'source': source, if (limit != null) 'limit': limit.toString(), @@ -121,7 +121,7 @@ class ConstellationClient { final data = await _get(uri); return ( total: data['total'] as int, - dids: _listField(data, 'dids').cast(), + dids: _listFieldAny(data, ['linking_dids', 'dids']).cast(), cursor: data['cursor'] as String?, ); } diff --git a/lib/features/feed/data/similar_posts_repository.dart b/lib/features/feed/data/similar_posts_repository.dart index fcc1e1d..0086e87 100644 --- a/lib/features/feed/data/similar_posts_repository.dart +++ b/lib/features/feed/data/similar_posts_repository.dart @@ -3,6 +3,7 @@ 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/actor_repository_service_resolver.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'; @@ -16,28 +17,30 @@ import 'package:poptart_core/poptart_core.dart' as atcore; /// 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. +/// it discovers liker DIDs, while public PDS repo reads discover those likers' +/// recent public likes. 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 +/// 1. Constellation receives the seed post URI and returns DIDs that liked it. +/// 2. The repository reads recent public `app.bsky.feed.like` records from +/// those likers' PDS repos. +/// 3. Candidate post URIs are ranked locally by shared-like count. +/// 4. The top URIs are hydrated with one `app.bsky.feed.getPosts` request. +/// 5. 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. +/// This keeps the repository/network cost bounded while avoiding a Constellation +/// many-to-many query that only returns the seed URI for Bluesky like records. class SimilarPostsRepository { SimilarPostsRepository({ required Bluesky bluesky, required ConstellationClient constellationClient, ModerationService? moderationService, + ActorRepositoryServiceResolver? actorRepositoryServiceResolver, String? appViewProvider, String Function()? appViewProviderResolver, Future Function()? onUnauthorized, @@ -45,18 +48,20 @@ class SimilarPostsRepository { DateTime Function()? now, Duration cacheTtl = const Duration(hours: 1), @visibleForTesting - Future<({List items, String? cursor})> Function(String postUri, String? cursor, int limit)? - relationshipLoader, + Future<({List dids, String? cursor})> Function(String postUri, String? cursor, int limit)? likerDidLoader, + @visibleForTesting Future> Function(String did, int limit)? recentLikedPostUriLoader, @visibleForTesting Future> Function(List uris)? postHydrator, }) : _constellationClient = constellationClient, _moderationService = moderationService, + _actorRepoResolver = actorRepositoryServiceResolver ?? ActorRepositoryServiceResolver(), _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ), _now = now ?? DateTime.now, _cacheTtl = cacheTtl, - _relationshipLoaderForTest = relationshipLoader, + _likerDidLoaderForTest = likerDidLoader, + _recentLikedPostUriLoaderForTest = recentLikedPostUriLoader, _postHydratorForTest = postHydrator { _authRecovery = UnauthorizedRecoveryRunner( initialClient: bluesky, @@ -71,25 +76,28 @@ class SimilarPostsRepository { late final UnauthorizedRecoveryRunner _authRecovery; final ConstellationClient _constellationClient; final ModerationService? _moderationService; + final ActorRepositoryServiceResolver _actorRepoResolver; 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<({List dids, String? cursor})> Function(String postUri, String? cursor, int limit)? + _likerDidLoaderForTest; + final Future> Function(String did, int limit)? _recentLikedPostUriLoaderForTest; final Future> Function(List uris)? _postHydratorForTest; final Map _cache = {}; - static const int defaultRelationshipLimit = 100; + static const int defaultRelationshipLimit = 12; + static const int defaultRecentLikesPerLiker = 50; 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]. + /// [cursor] is the Constellation cursor from a previous liker-DID 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/repo/hydration work inside [_cacheTtl]. Future getSimilarPosts({ required String postUri, String? cursor, @@ -107,41 +115,91 @@ class SimilarPostsRepository { return cached.page; } - final relationships = await _loadRelationships(normalizedPostUri, cursor, relationshipLimit); - final rankedUris = _rankCandidateUris(normalizedPostUri, relationships.items); + final likers = await _loadLikerDids(normalizedPostUri, cursor, relationshipLimit); + if (likers.dids.isEmpty) { + final empty = SimilarPostsPage(posts: const [], cursor: likers.cursor); + _cache[cacheKey] = _CachedSimilarPostsPage(empty, _now()); + return empty; + } + + final candidateUris = await _loadRecentLikedPostUrisForLikers(likers.dids, defaultRecentLikesPerLiker); + final rankedUris = _rankCandidateUris(normalizedPostUri, candidateUris); if (rankedUris.isEmpty) { - final empty = SimilarPostsPage(posts: const [], cursor: relationships.cursor); + final empty = SimilarPostsPage(posts: const [], cursor: likers.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); + final page = SimilarPostsPage(posts: hydrated, cursor: likers.cursor); _cache[cacheKey] = _CachedSimilarPostsPage(page, _now()); return page; } - Future<({List items, String? cursor})> _loadRelationships(String postUri, String? cursor, int limit) { - final loader = _relationshipLoaderForTest; + Future<({List dids, String? cursor})> _loadLikerDids(String postUri, String? cursor, int limit) async { + final loader = _likerDidLoaderForTest; if (loader != null) { return loader(postUri, cursor, limit); } - return _constellationClient.getManyToMany( + final result = await _constellationClient.getDistinct( postUri, similarPostsLikeSource, - 'subject.uri', limit: limit, cursor: cursor, ); + return (dids: result.dids, cursor: result.cursor); + } + + Future> _loadRecentLikedPostUrisForLikers(List likerDids, int limitPerLiker) async { + final candidateUris = []; + for (final did in likerDids) { + try { + candidateUris.addAll(await _loadRecentLikedPostUris(did, limitPerLiker)); + } catch (error, stackTrace) { + log.d('similar_posts failed to load recent likes for $did', error: error, stackTrace: stackTrace); + } + } + return candidateUris; + } + + Future> _loadRecentLikedPostUris(String did, int limit) async { + final loader = _recentLikedPostUriLoaderForTest; + if (loader != null) { + return loader(did, limit); + } + + final resolved = await _actorRepoResolver.resolve(did); + final response = await _authRecovery.run( + (client) => client.atproto.repo.listRecords( + repo: resolved.did, + collection: 'app.bsky.feed.like', + limit: limit.clamp(1, 100), + reverse: false, + $service: resolved.pdsHost, + ), + ); + + final uris = []; + for (final record in response.data.records) { + final subject = record.value['subject']; + if (subject is! Map) { + continue; + } + final uri = subject['uri']; + if (uri is String && uri.isNotEmpty) { + uris.add(uri); + } + } + return uris; } - /// 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) { + /// Counts duplicate candidate URIs. 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 candidateUris) { final counts = {}; - for (final item in items) { - final candidateUri = item.otherSubject.trim(); + for (final value in candidateUris) { + final candidateUri = value.trim(); if (candidateUri.isEmpty || candidateUri == seedPostUri || !_isPostUri(candidateUri)) { continue; } diff --git a/lib/features/feed/presentation/widgets/compact_post_card.dart b/lib/features/feed/presentation/widgets/compact_post_card.dart index 229e16f..354af35 100644 --- a/lib/features/feed/presentation/widgets/compact_post_card.dart +++ b/lib/features/feed/presentation/widgets/compact_post_card.dart @@ -23,12 +23,14 @@ class CompactPostCard extends StatelessWidget { required this.feedViewPost, this.footer, this.onTap, + this.contentPadding = const EdgeInsets.fromLTRB(12, 10, 12, 10), this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, }); final FeedViewPost feedViewPost; final Widget? footer; final VoidCallback? onTap; + final EdgeInsetsGeometry contentPadding; final bsky_moderation.ModerationBehaviorContext moderationContext; @override @@ -51,7 +53,7 @@ class CompactPostCard extends StatelessWidget { InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + padding: contentPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/feed/presentation/widgets/similar_posts_section.dart b/lib/features/feed/presentation/widgets/similar_posts_section.dart index 2adc9fe..c7b1488 100644 --- a/lib/features/feed/presentation/widgets/similar_posts_section.dart +++ b/lib/features/feed/presentation/widgets/similar_posts_section.dart @@ -57,36 +57,8 @@ class SimilarPostsSection extends StatelessWidget { } 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( + trailing: state.hasMore + ? TextButton.icon( onPressed: state.status == SimilarPostsStatus.loadingMore ? null : () => context.read().loadMore(), @@ -94,8 +66,34 @@ class SimilarPostsSection extends StatelessWidget { ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.expand_more), label: const Text('Show more'), + ) + : null, + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth.clamp(272.0, 372.0).toDouble(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: state.posts + .map( + (post) => Padding( + padding: const EdgeInsets.only(right: 12), + child: SizedBox( + width: cardWidth, + child: CompactPostCard( + feedViewPost: FeedViewPost(post: post), + contentPadding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + onTap: () => navigateToPost(context, post.uri.toString()), + ), + ), + ), + ) + .toList(growable: false), ), - ], + ); + }, ), ); }, @@ -104,9 +102,10 @@ class SimilarPostsSection extends StatelessWidget { } class _SimilarPostsShell extends StatelessWidget { - const _SimilarPostsShell({required this.child}); + const _SimilarPostsShell({required this.child, this.trailing}); final Widget child; + final Widget? trailing; @override Widget build(BuildContext context) { @@ -115,13 +114,26 @@ class _SimilarPostsShell extends StatelessWidget { 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), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + 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), + ), + ], + ), + ), + if (trailing != null) Padding(padding: const EdgeInsetsDirectional.only(start: 12), child: trailing!), + ], ), - const SizedBox(height: 10), + const SizedBox(height: 12), child, ], ), diff --git a/test/core/network/constellation_client_test.dart b/test/core/network/constellation_client_test.dart index 5d9e05c..574fe00 100644 --- a/test/core/network/constellation_client_test.dart +++ b/test/core/network/constellation_client_test.dart @@ -136,7 +136,7 @@ void main() { test('returns total, dids and cursor from response', () async { final responseBody = jsonEncode({ 'total': 3, - 'dids': ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], + 'linking_dids': ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], 'cursor': 'next-cursor', }); final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); @@ -161,7 +161,7 @@ void main() { }); test('treats null dids as empty list', () async { - final responseBody = jsonEncode({'total': 1, 'dids': null}); + final responseBody = jsonEncode({'total': 1, 'linking_dids': null}); final client = ConstellationClient(httpClient: MockClient((_) async => http.Response(responseBody, 200))); final result = await client.getDistinct('did:plc:abc', 'source'); @@ -174,7 +174,7 @@ void main() { final client = ConstellationClient( httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); + return http.Response(jsonEncode({'total': 0, 'linking_dids': []}), 200); }), ); @@ -182,6 +182,7 @@ void main() { expect(capturedUri?.queryParameters['limit'], '25'); expect(capturedUri?.queryParameters['cursor'], 'some-cursor'); + expect(capturedUri?.path, '/xrpc/blue.microcosm.links.getBacklinkDids'); }); test('omits limit and cursor when not provided', () async { @@ -189,7 +190,7 @@ void main() { final client = ConstellationClient( httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response(jsonEncode({'total': 0, 'dids': []}), 200); + return http.Response(jsonEncode({'total': 0, 'linking_dids': []}), 200); }), ); diff --git a/test/features/feed/cubit/similar_posts_cubit_test.dart b/test/features/feed/cubit/similar_posts_cubit_test.dart index 1a9fcc0..c1496ac 100644 --- a/test/features/feed/cubit/similar_posts_cubit_test.dart +++ b/test/features/feed/cubit/similar_posts_cubit_test.dart @@ -16,7 +16,7 @@ void main() { blocTest( 'loads the first page on demand', build: () => SimilarPostsCubit( - repository: _repository(items: [_item('at://did:plc:one/app.bsky.feed.post/a')], cursor: 'next'), + repository: _repository(candidateUris: ['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: () => [ @@ -33,17 +33,23 @@ void main() { 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, - ); - }), + repository: _repositoryWithLoader( + (_, cursor, _) async { + call++; + if (call == 1) { + return (dids: ['did:plc:liker1'], cursor: 'next'); + } + expect(cursor, 'next'); + return (dids: ['did:plc:liker2'], cursor: null); + }, + recentLikedPostUriLoader: (did, _) async { + return switch (did) { + 'did:plc:liker1' => ['at://did:plc:one/app.bsky.feed.post/a'], + 'did:plc:liker2' => ['at://did:plc:one/app.bsky.feed.post/a', 'at://did:plc:two/app.bsky.feed.post/b'], + _ => const [], + }; + }, + ), ); }, act: (cubit) async { @@ -64,25 +70,24 @@ void main() { }); } -SimilarPostsRepository _repository({required List items, String? cursor}) => - _repositoryWithLoader((_, _, _) async => (items: items, cursor: cursor)); +SimilarPostsRepository _repository({required List candidateUris, String? cursor}) => _repositoryWithLoader( + (_, _, _) async => (dids: ['did:plc:liker'], cursor: cursor), + recentLikedPostUriLoader: (_, _) async => candidateUris, +); SimilarPostsRepository _repositoryWithLoader( - Future<({List items, String? cursor})> Function(String postUri, String? cursor, int limit) loader, -) { + Future<({List dids, String? cursor})> Function(String postUri, String? cursor, int limit) loader, { + required Future> Function(String did, int limit) recentLikedPostUriLoader, +}) { return SimilarPostsRepository( bluesky: MockBluesky(), constellationClient: ConstellationClient(), - relationshipLoader: loader, + likerDidLoader: loader, + recentLikedPostUriLoader: recentLikedPostUriLoader, 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) => testPostView( uri: uri, cid: 'cid-$uri', diff --git a/test/features/feed/data/similar_posts_repository_test.dart b/test/features/feed/data/similar_posts_repository_test.dart index 5f78fe3..505abcf 100644 --- a/test/features/feed/data/similar_posts_repository_test.dart +++ b/test/features/feed/data/similar_posts_repository_test.dart @@ -11,25 +11,30 @@ class MockBluesky extends Mock implements Bluesky {} void main() { group('SimilarPostsRepository', () { - test('loads relationships, ranks shared-like duplicates, and hydrates only the top candidates', () async { + test('loads likers, reads their public likes, ranks duplicates, and hydrates top candidates', () async { final hydratedUris = >[]; + final recentLikeCalls = []; final repository = SimilarPostsRepository( bluesky: MockBluesky(), constellationClient: ConstellationClient(), - relationshipLoader: (postUri, cursor, limit) async { + likerDidLoader: (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'), + expect(limit, SimilarPostsRepository.defaultRelationshipLimit); + return (dids: ['did:plc:liker1', 'did:plc:liker2'], cursor: 'next'); + }, + recentLikedPostUriLoader: (did, limit) async { + recentLikeCalls.add('$did:$limit'); + return switch (did) { + 'did:plc:liker1' => [ + 'at://did:plc:one/app.bsky.feed.post/a', + 'at://did:plc:two/app.bsky.feed.post/b', + 'at://did:plc:seed/app.bsky.feed.post/root', + 'not-a-uri', ], - cursor: 'next', - ); + 'did:plc:liker2' => ['at://did:plc:one/app.bsky.feed.post/a', 'at://did:plc:three/app.bsky.feed.post/c'], + _ => const [], + }; }, postHydrator: (uris) async { hydratedUris.add(uris.map((uri) => uri.toString()).toList()); @@ -40,7 +45,15 @@ void main() { 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(recentLikeCalls, [ + 'did:plc:liker1:${SimilarPostsRepository.defaultRecentLikesPerLiker}', + 'did:plc:liker2:${SimilarPostsRepository.defaultRecentLikesPerLiker}', + ]); + expect(hydratedUris.single, [ + 'at://did:plc:one/app.bsky.feed.post/a', + 'at://did:plc:three/app.bsky.feed.post/c', + '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', @@ -52,15 +65,13 @@ void main() { 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, - ), + likerDidLoader: (_, _, _) async => (dids: ['did:plc:liker'], cursor: null), + recentLikedPostUriLoader: (_, _) async => const [ + '', + 'not-a-uri', + 'at://did:plc:seed/app.bsky.feed.post/root', + 'at://did:plc:actor/app.bsky.feed.like/rkey', + ], postHydrator: (_) async { hydrateCalled = true; return const []; @@ -75,16 +86,17 @@ void main() { test('caches pages within the configured ttl', () async { var now = DateTime.utc(2026, 5, 23, 12); - var relationshipCalls = 0; + var likerCalls = 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); + likerDidLoader: (_, _, _) async { + likerCalls++; + return (dids: ['did:plc:liker'], cursor: null); }, + recentLikedPostUriLoader: (_, _) async => ['at://did:plc:one/app.bsky.feed.post/a'], postHydrator: (_) async => [_post('at://did:plc:one/app.bsky.feed.post/a')], ); @@ -93,16 +105,11 @@ void main() { now = now.add(const Duration(hours: 2)); await repository.getSimilarPosts(postUri: 'at://did:plc:seed/app.bsky.feed.post/root'); - expect(relationshipCalls, 2); + expect(likerCalls, 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) => testPostView( uri: uri, cid: 'cid-$uri', diff --git a/test/features/feed/presentation/similar_posts_section_test.dart b/test/features/feed/presentation/similar_posts_section_test.dart index 287dc52..0c36169 100644 --- a/test/features/feed/presentation/similar_posts_section_test.dart +++ b/test/features/feed/presentation/similar_posts_section_test.dart @@ -6,6 +6,7 @@ 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/compact_post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/similar_posts_section.dart'; import 'package:mocktail/mocktail.dart'; @@ -26,6 +27,23 @@ void main() { await tester.pumpAndSettle(); expect(find.text('author.example'), findsOneWidget); + final card = tester.widget(find.byType(CompactPostCard)); + expect(card.contentPadding, const EdgeInsets.fromLTRB(16, 14, 16, 14)); + }); + + testWidgets('places Show more in the section header instead of below the cards', (tester) async { + await tester.pumpWidget( + _TestApp( + repository: _repository(posts: [_post('at://did:plc:one/app.bsky.feed.post/a')], cursor: 'next'), + ), + ); + + await tester.tap(find.text('Show similar posts')); + await tester.pumpAndSettle(); + + final showMoreTop = tester.getTopLeft(find.widgetWithText(TextButton, 'Show more')).dy; + final cardBottom = tester.getBottomLeft(find.byType(CompactPostCard)).dy; + expect(showMoreTop, lessThan(cardBottom)); }); } @@ -47,19 +65,14 @@ class _TestApp extends StatelessWidget { } } -SimilarPostsRepository _repository({required List posts}) => SimilarPostsRepository( +SimilarPostsRepository _repository({required List posts, String? cursor}) => SimilarPostsRepository( bluesky: MockBluesky(), constellationClient: ConstellationClient(), - relationshipLoader: (_, _, _) async => - (items: posts.map((post) => _item(post.uri.toString())).toList(), cursor: null), + likerDidLoader: (_, _, _) async => (dids: ['did:plc:liker'], cursor: cursor), + recentLikedPostUriLoader: (_, _) async => posts.map((post) => post.uri.toString()).toList(), 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) => testPostView( uri: uri, cid: 'cid-$uri', diff --git a/test/features/profile/data/profile_context_repository_test.dart b/test/features/profile/data/profile_context_repository_test.dart index 31d2a4a..589c897 100644 --- a/test/features/profile/data/profile_context_repository_test.dart +++ b/test/features/profile/data/profile_context_repository_test.dart @@ -331,15 +331,15 @@ void main() { }); group('getBlockedByProfiles', () { - test('hydrates DIDs returned by getDistinct', () async { + test('hydrates DIDs returned by getBacklinkDids', () async { final aliceProfile = _buildProfileView('did:plc:alice', 'alice.bsky.social'); final bobProfile = _buildProfileView('did:plc:bob', 'bob.bsky.social'); final constellation = _constellationWithResponses((uri) { - expect(uri.path, contains('getDistinct')); + expect(uri.path, contains('getBacklinkDids')); return { 'total': 2, - 'dids': ['did:plc:alice', 'did:plc:bob'], + 'linking_dids': ['did:plc:alice', 'did:plc:bob'], }; }); @@ -363,7 +363,7 @@ void main() { final constellation = _constellationWithResponses((_) { return { 'total': 1, - 'dids': ['did:plc:alice'], + 'linking_dids': ['did:plc:alice'], }; }); @@ -398,7 +398,7 @@ void main() { constellationClient: _constellationWithResponses((_) { return { 'total': 1, - 'dids': ['did:plc:alice'], + 'linking_dids': ['did:plc:alice'], }; }), ); @@ -427,7 +427,7 @@ void main() { constellationClient: _constellationWithResponses((_) { return { 'total': 2, - 'dids': ['did:plc:alice', 'did:plc:suspended'], + 'linking_dids': ['did:plc:alice', 'did:plc:suspended'], }; }), ); @@ -456,7 +456,7 @@ void main() { constellationClient: _constellationWithResponses((_) { return { 'total': 1, - 'dids': ['did:plc:alice'], + 'linking_dids': ['did:plc:alice'], }; }), ); @@ -465,23 +465,23 @@ void main() { }); test('uses local offset cursor after collecting blocked-by DIDs', () async { - var getDistinctCalls = 0; + var getBacklinkDidsCalls = 0; String? capturedLimit; final constellation = _constellationWithResponses((uri) { - getDistinctCalls += 1; + getBacklinkDidsCalls += 1; capturedLimit = uri.queryParameters['limit']; - if (getDistinctCalls == 1) { + if (getBacklinkDidsCalls == 1) { expect(uri.queryParameters['cursor'], isNull); return { 'total': 3, - 'dids': ['did:plc:one', 'did:plc:two'], + 'linking_dids': ['did:plc:one', 'did:plc:two'], 'cursor': 'page2', }; } expect(uri.queryParameters['cursor'], 'page2'); return { 'total': 3, - 'dids': ['did:plc:three'], + 'linking_dids': ['did:plc:three'], 'cursor': null, }; }); @@ -499,19 +499,19 @@ void main() { final result = await repo.getBlockedByProfiles('did:plc:target', cursor: '2'); - expect(getDistinctCalls, 2); + expect(getBacklinkDidsCalls, 2); expect(capturedLimit, '16'); expect(result.entries.map((entry) => entry.did), ['did:plc:three']); expect(result.cursor, isNull); }); - test('falls back to getBacklinks when getDistinct returns 404', () async { + test('falls back to getBacklinks when getBacklinkDids returns 404', () async { final aliceProfile = _buildProfileViewDetailed('did:plc:alice', 'alice.bsky.social'); final bobProfile = _buildProfileViewDetailed('did:plc:bob', 'bob.bsky.social'); final constellation = ConstellationClient( httpClient: MockClient((request) async { - if (request.url.path.contains('getDistinct')) { + if (request.url.path.contains('getBacklinkDids')) { return http.Response('Not Found', 404); } @@ -546,7 +546,7 @@ void main() { }); test('collects blocked-by DIDs across pages before hydrating', () async { - var getDistinctCalls = 0; + var getBacklinkDidsCalls = 0; final repo = ProfileContextRepository( bluesky: _fakeBluesky( actor: _ThrowingActorService(), @@ -562,16 +562,16 @@ void main() { atproto: _FakeAtProto(repo: _FakeRepoService(records: [])), ), constellationClient: _constellationWithResponses((uri) { - if (!uri.path.contains('getDistinct')) { + if (!uri.path.contains('getBacklinkDids')) { throw Exception('Unexpected endpoint: ${uri.path}'); } - getDistinctCalls += 1; - if (getDistinctCalls == 1) { + getBacklinkDidsCalls += 1; + if (getBacklinkDidsCalls == 1) { expect(uri.queryParameters['cursor'], isNull); return { 'total': 23, - 'dids': ['did:plc:suspended'], + 'linking_dids': ['did:plc:suspended'], 'cursor': 'page-2', }; } @@ -579,7 +579,7 @@ void main() { expect(uri.queryParameters['cursor'], 'page-2'); return { 'total': 23, - 'dids': ['did:plc:alice'], + 'linking_dids': ['did:plc:alice'], 'cursor': null, }; }), @@ -587,7 +587,7 @@ void main() { final result = await repo.getBlockedByProfiles('did:plc:target'); - expect(getDistinctCalls, 2); + expect(getBacklinkDidsCalls, 2); expect(result.total, 23); expect(result.cursor, isNull); expect(result.entries.map((entry) => entry.did), ['did:plc:suspended', 'did:plc:alice']); @@ -600,7 +600,7 @@ void main() { final profiles = dids.map((d) => _buildProfileView(d, '$d.bsky.social')).toList(); final batchSizes = []; - final constellation = _constellationWithResponses((_) => {'total': 30, 'dids': dids}); + final constellation = _constellationWithResponses((_) => {'total': 30, 'linking_dids': dids}); final repo = ProfileContextRepository( bluesky: _fakeBluesky( @@ -619,7 +619,7 @@ void main() { }); test('returns empty profiles when no DIDs returned', () async { - final constellation = _constellationWithResponses((_) => {'total': 0, 'dids': []}); + final constellation = _constellationWithResponses((_) => {'total': 0, 'linking_dids': []}); final repo = ProfileContextRepository(bluesky: _buildBluesky(), constellationClient: constellation); final result = await repo.getBlockedByProfiles('did:plc:target'); @@ -663,7 +663,7 @@ void main() { final constellation = ConstellationClient( httpClient: MockClient((request) async { - if (request.url.path.contains('getDistinct')) { + if (request.url.path.contains('getBacklinkDids')) { return http.Response('Not Found', 404); } @@ -1038,7 +1038,7 @@ void main() { constellationClient: _constellationWithResponses((_) { return { 'total': 3, - 'dids': [' did:plc:alice ', 'did:plc:alice', ' '], + 'linking_dids': [' did:plc:alice ', 'did:plc:alice', ' '], }; }), );