diff --git a/docs/TODO.md b/docs/TODO.md index b5d6d25..929c450 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -49,41 +49,37 @@ updated: 2026-05-23 ### Relational Discovery -- Post threads: show quote posts for the focused post. - - Discover candidate posts from `app.bsky.feed.post` record embeds that point at - the focused post. - - Hydrate through AppView before rendering and apply existing moderation filtering. -- Post/link cards: show more posts about the same link or domain. - - Use post facets/external embeds for URL/domain relationships. - - Rank recent/high-signal posts first and cap repeated authors. - Profiles: show starter packs featuring the account. - Use `app.bsky.graph.starterpackitem:subject` relationships. - Hydrate starter packs before rendering and filter unsafe/unavailable records. - Profiles: show public lists containing the account. - Use `app.bsky.graph.listitem:subject` relationships. - Be careful with abusive list names/descriptions; hydrate/filter before rendering. + - This should go in "Context" - Profiles: show similar accounts. - Define as accounts often followed by the same people, or accounts followed by people who follow this account. - Add ranking dampening so celebrity accounts do not dominate results. -- Post threads: show "Also reposted" posts. - - Define as posts reposted by accounts who reposted the focused post. - - Keep the relationship lookup plus AppView hydration bounded to one page at a time. -- Thread roots: show other active branches in the same conversation. - - Use `app.bsky.feed.post` reply root/parent relationships. - - Rank by recent activity and collapse noisy branches by default. + - Could be combined with "Suggested Follows" view - Lists/starter packs: show similar lists and similar starter packs by overlapping members. - Hydrate records before display and avoid surfacing unavailable/deleted records. -- Profiles: show posts mentioning the account. - - Use mention facets pointing at the account DID. - - Hydrate through AppView, apply existing moderation filtering, then rank for - usefulness. - - MVP ranking guardrails: prefer non-replies, recent/high-engagement posts, cap posts - per author, and deprioritize posts with many mentions or repeated text. --- +- Post threads: show quote posts for the focused post. + - Discover candidate posts from `app.bsky.feed.post` record embeds that point at + the focused post. + - Hydrate through AppView before rendering and apply existing moderation filtering. +- Post/link cards: show more posts about the same link or domain. + - Use post facets/external embeds for URL/domain relationships. + - Rank recent/high-signal posts first and cap repeated authors. +- Thread roots: show other active branches in the same conversation. + - Use `app.bsky.feed.post` reply root/parent relationships. + - Rank by recent activity and collapse noisy branches by default. + +### Nice-to-Have + - Adding `/rss` for public BlueSky profiles shows their profile as an RSS feed. It would be cool to display this and allow exporting the feed or a link to it. - In dev tools, show Firehose, Jetstream, and diff --git a/lib/core/router/app_route_paths.dart b/lib/core/router/app_route_paths.dart index db5f51a..6d1a36c 100644 --- a/lib/core/router/app_route_paths.dart +++ b/lib/core/router/app_route_paths.dart @@ -126,6 +126,9 @@ enum AppRoutePath { AppRoutePath.topic => '/topic', AppRoutePath.publicProfile => r'/profile/:actor(m|[^m][^/]*|m[^e][^/]*|me[^/]+)', AppRoutePath.profileMe => '/profile/me', + // TODO: consider refactoring/nesting this such that it doesn't require + // query params, i.e. `/profile/:actor/context` over passing handle & did + // in calls to context.push AppRoutePath.profileContext => '/profile-context', AppRoutePath.images => '/images', AppRoutePath.video => '/video', diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index dc1b7f8..4c3e40c 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -6,6 +6,7 @@ import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/actor/profile.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/like.dart'; +import 'package:bluesky_poptart/app/bsky/feed/search_posts.dart'; import 'package:characters/characters.dart'; import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; @@ -187,6 +188,45 @@ class ProfileRepository { return ProfileConnectionsPage(subject: response.data.subject, profiles: profiles, cursor: response.data.cursor); } + /// Finds posts that mention [actor] via rich-text mention facets. + /// + /// `app.bsky.feed.searchPosts` runs on AppView and returns hydrated [PostView] + /// records, so the records have already passed through the network layer + /// that knows about deletions, takedowns, blocks, labels, embeds, and viewer + /// state. This repository still applies Lazurite's moderation service before + /// ranking so the pane never renders content hidden by the viewer's settings. + /// + /// Ranking is intentionally lightweight. Non-replies and recent/high-signal + /// posts are promoted, posts with many mentions or repeated text are demoted, + /// and each page caps repeated authors to keep one account from flooding a + /// profile's mentions tab. + Future getActorMentions({required String actor, String? cursor, int limit = 50}) async { + final normalizedActor = actor.trim(); + if (normalizedActor.isEmpty) { + return const ProfileMentionsResult(posts: []); + } + + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.searchPosts', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.searchPosts( + q: '*', + mentions: normalizedActor, + sort: const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest), + cursor: cursor, + limit: limit.clamp(1, 100), + $headers: headers, + ), + ); + + final visiblePosts = _moderationService == null + ? response.data.posts + : response.data.posts.where((post) => !_moderationService.shouldFilterPostInList(post)).toList(); + return ProfileMentionsResult(posts: _rankMentionPosts(visiblePosts), cursor: response.data.cursor); + } + /// Likes transport matrix: /// - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context /// (PDS-routed, read-after-write behavior for the current account). @@ -527,6 +567,111 @@ class ProfileRepository { return records; } + /// Orders one AppView page of mention results for usefulness rather than + /// raw recency. + /// + /// AppView search is still the source of truth for membership in the result + /// set. This method only reorders and trims that page so the top of the tab + /// is less likely to be dominated by: + /// - reply spam + /// - mass-mention posts + /// - duplicate text + /// - one prolific author + List _rankMentionPosts(List posts) { + final repeatedTextCounts = {}; + for (final post in posts) { + final textKey = _normalizedMentionText(post); + if (textKey.isEmpty) continue; + repeatedTextCounts[textKey] = (repeatedTextCounts[textKey] ?? 0) + 1; + } + + final ranked = posts.toList() + ..sort((a, b) { + final aScore = _mentionPostScore(a, repeatedTextCounts); + final bScore = _mentionPostScore(b, repeatedTextCounts); + final byScore = bScore.compareTo(aScore); + if (byScore != 0) return byScore; + final byDate = b.indexedAt.compareTo(a.indexedAt); + if (byDate != 0) return byDate; + return a.uri.toString().compareTo(b.uri.toString()); + }); + + final authorCounts = {}; + final capped = []; + const maxPostsPerAuthor = 2; + for (final post in ranked) { + final authorDid = post.author.did; + final count = authorCounts[authorDid] ?? 0; + if (count >= maxPostsPerAuthor) continue; + authorCounts[authorDid] = count + 1; + capped.add(post); + } + return capped; + } + + /// Computes a simple, deterministic score for a mention candidate. + /// + /// The constants are intentionally coarse to encode priorities instead of an + /// explicit model. + /// Direct posts should beat replies, newer posts should generally beat older + /// posts, engagement is a small boost, and common spam shapes are small + /// penalties. + /// + /// The URI tie-breaker in [_rankMentionPosts] keeps ordering stable when + /// scores match. + int _mentionPostScore(PostView post, Map repeatedTextCounts) { + final isReply = _isReplyPost(post); + final mentionCount = _mentionCount(post); + final textKey = _normalizedMentionText(post); + final repeatedTextPenalty = textKey.isNotEmpty && (repeatedTextCounts[textKey] ?? 0) > 1 ? 30 : 0; + final engagement = + (post.likeCount ?? 0) + (post.repostCount ?? 0) + (post.replyCount ?? 0) + (post.quoteCount ?? 0); + final recencyBucket = post.indexedAt.millisecondsSinceEpoch ~/ Duration.millisecondsPerHour; + + return (isReply ? 0 : 1000000) + + recencyBucket.clamp(0, 900000) + + engagement.clamp(0, 5000) * 10 - + mentionCount.clamp(0, 20) * 25 - + repeatedTextPenalty; + } + + bool _isReplyPost(PostView post) => post.record['reply'] != null; + + /// Counts rich-text mention features on a post record. + /// + /// A high count often means a broad callout or spammy mention blast, so the + /// ranking step treats it as a weak negative signal. + /// + /// Missing or malformed facet data is treated as zero mentions rather than an + /// error because AppView has already accepted the post as a search result. + int _mentionCount(PostView post) { + final facets = post.record['facets']; + if (facets is! List) return 0; + var count = 0; + for (final facet in facets) { + if (facet is! Map) continue; + final features = facet['features']; + if (features is! List) continue; + for (final feature in features) { + if (feature is Map && feature[r'$type'] == 'app.bsky.richtext.facet#mention') { + count++; + } + } + } + return count; + } + + /// Normalizes text for duplicate detection within a single search page. + /// + /// Avoids heavyweight near-duplicate matching. Exact text after + /// whitespace/case normalization *should* be enough to demote obvious + /// repeated promo or bot posts without making ranking hard to reason about. + String _normalizedMentionText(PostView post) { + final text = post.record['text']; + if (text is! String) return ''; + return text.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + } + DateTime? _extractLikedAtFromReason(UFeedViewPostReason? reason) { if (reason == null) { return null; @@ -546,24 +691,22 @@ class ProfileRepository { 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, - ); - } + ProfileView _profileViewFromDetailed(ProfileViewDetailed profile) => 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 { @@ -595,6 +738,17 @@ class ProfileImageUpload { final String mimeType; } +/// A page of hydrated, moderation-filtered posts mentioning a profile. +/// +/// [cursor] is the AppView search cursor. Callers should pass it unchanged when +/// loading the next page; ranking is applied per page rather than globally. +class ProfileMentionsResult { + const ProfileMentionsResult({required this.posts, this.cursor}); + + final List posts; + final String? cursor; +} + class ProfileActorLikesResult { const ProfileActorLikesResult({required this.entries, this.cursor}); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 87040a3..739775d 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -28,7 +28,6 @@ import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; -import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; @@ -40,6 +39,7 @@ import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; import 'package:lazurite/features/profile/presentation/widgets/profile_liked_posts_pane.dart'; +import 'package:lazurite/features/profile/presentation/widgets/profile_mentions_pane.dart'; import 'package:lazurite/features/profile/presentation/widgets/profile_starter_packs_pane.dart'; import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; @@ -53,6 +53,7 @@ import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/shared/utils/url_utils.dart'; +import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; enum _ProfileFeedSlice { posts, replies, quotes, reposts, media } @@ -88,8 +89,8 @@ class _ProfileScreenState extends State with TickerProviderStateM _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithMedia, slice: _ProfileFeedSlice.media), ]; - static const _baseTabCountOwn = 7; - static const _baseTabCountOther = 8; + static const _baseTabCountOwn = 8; + static const _baseTabCountOther = 9; static const _coverRefreshTriggerDistance = 72.0; late TabController _tabController; @@ -287,6 +288,7 @@ class _ProfileScreenState extends State with TickerProviderStateM l10n.labelReposts.toUpperCase(), l10n.labelMedia.toUpperCase(), if (_showSuggestedTab) l10n.labelLiked.toUpperCase(), + l10n.labelMentions.toUpperCase(), l10n.labelLists.toUpperCase(), l10n.labelStarterPacks.toUpperCase(), if (_showSuggestedTab) l10n.labelSuggested.toUpperCase(), @@ -529,6 +531,11 @@ class _ProfileScreenState extends State with TickerProviderStateM key: const PageStorageKey('profile-liked-tab'), child: _buildLikedPostsTab(context, actorScopedProfile), ), + if (!_isPublic) + KeyedSubtree( + key: const PageStorageKey('profile-mentions-tab'), + child: _buildMentionsTab(context, actorScopedProfile), + ), if (!_isPublic) KeyedSubtree( key: const PageStorageKey('profile-lists-tab'), @@ -668,29 +675,25 @@ class _ProfileScreenState extends State with TickerProviderStateM ); } - Widget _buildProfileFabs(BuildContext context) { - final jumpToTopButton = FloatingActionButton.small( - key: const ValueKey('profile-jump-top-fab'), - heroTag: 'profile-jump-top-fab', - tooltip: context.l10n.tooltipJumpToTop, - onPressed: _jumpToTop, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), - foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, - elevation: 1.5, - child: const Icon(Icons.arrow_upward, size: 18), - ); - - return Row( - mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox(width: 24), - jumpToTopButton, - const Spacer(), - _buildComposeFab(context), - const SizedBox(width: 24), - ], - ); - } + Widget _buildProfileFabs(BuildContext context) => Row( + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(width: 24), + FloatingActionButton.small( + key: const ValueKey('profile-jump-top-fab'), + heroTag: 'profile-jump-top-fab', + tooltip: context.l10n.tooltipJumpToTop, + onPressed: _jumpToTop, + backgroundColor: context.colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + foregroundColor: context.colorScheme.onSurfaceVariant, + elevation: 1.5, + child: const Icon(Icons.arrow_upward, size: 18), + ), + const Spacer(), + _buildComposeFab(context), + const SizedBox(width: 24), + ], + ); Widget _buildCoverSection(BuildContext context, ProfileViewDetailed? profile) { final width = MediaQuery.of(context).size.width; @@ -772,21 +775,19 @@ class _ProfileScreenState extends State with TickerProviderStateM ); } - Widget _buildProfileError(BuildContext context, String? errorMessage) { - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.errorFailedToLoadProfile, style: context.textTheme.titleLarge), - const SizedBox(height: 8), - Text(errorMessage ?? context.l10n.errorUnknown, style: context.textTheme.bodyMedium), - const SizedBox(height: 12), - FilledButton(onPressed: _loadProfileAndFeed, child: Text(context.l10n.buttonTryAgain)), - ], - ), - ); - } + Widget _buildProfileError(BuildContext context, String? errorMessage) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.errorFailedToLoadProfile, style: context.textTheme.titleLarge), + const SizedBox(height: 8), + Text(errorMessage ?? context.l10n.errorUnknown, style: context.textTheme.bodyMedium), + const SizedBox(height: 12), + FilledButton(onPressed: _loadProfileAndFeed, child: Text(context.l10n.buttonTryAgain)), + ], + ), + ); Widget _buildProfileSummary(BuildContext context, ProfileViewDetailed? profile, bool isOwnProfile) { if (profile == null) return const SizedBox.shrink(); @@ -1521,6 +1522,21 @@ class _ProfileScreenState extends State with TickerProviderStateM return ProfileLikedPostsPane(actor: actor, profileRepository: profileRepository); } + Widget _buildMentionsTab(BuildContext context, ProfileViewDetailed? profile) { + final actorDid = profile?.did; + if (actorDid == null || actorDid.isEmpty) return const SizedBox.shrink(); + + ProfileRepository? profileRepository; + try { + profileRepository = context.read(); + } catch (error, stackTrace) { + log.d('ProfileScreen: ProfileRepository unavailable for mentions tab', error: error, stackTrace: stackTrace); + return const SizedBox.shrink(); + } + + return ProfileMentionsPane(actorDid: actorDid, profileRepository: profileRepository); + } + Widget _buildStarterPacksTab(BuildContext context, ProfileViewDetailed? profile) { final actor = profile?.did ?? _resolvedActor; if (actor == null) return const SizedBox.shrink(); @@ -1605,17 +1621,15 @@ class _ProfileReplyThreadItem extends StatelessWidget { ); } - Widget _buildThreadConnector(BuildContext context) { - return SizedBox( - height: 16, - child: Row( - children: [ - const SizedBox(width: 37), - Container(width: 2, color: Theme.of(context).dividerColor), - ], - ), - ); - } + Widget _buildThreadConnector(BuildContext context) => SizedBox( + height: 16, + child: Row( + children: [ + const SizedBox(width: 37), + Container(width: 2, color: Theme.of(context).dividerColor), + ], + ), + ); } class _SuggestedFollowsTab extends StatefulWidget { diff --git a/lib/features/profile/presentation/widgets/profile_mentions_pane.dart b/lib/features/profile/presentation/widgets/profile_mentions_pane.dart new file mode 100644 index 0000000..5f81c96 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_mentions_pane.dart @@ -0,0 +1,173 @@ +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/l10n/l10n.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; + +/// Profile tab that shows posts whose rich-text facets mention the profile DID. +class ProfileMentionsPane extends StatefulWidget { + const ProfileMentionsPane({super.key, required this.actorDid, required this.profileRepository}); + + final String actorDid; + final ProfileRepository profileRepository; + + @override + State createState() => _ProfileMentionsPaneState(); +} + +class _ProfileMentionsPaneState extends State { + List _posts = const []; + String? _cursor; + bool _isLoading = true; + bool _isLoadingMore = false; + bool _hasMore = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadInitial(); + } + + @override + void didUpdateWidget(covariant ProfileMentionsPane oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actorDid != widget.actorDid) { + _loadInitial(); + } + } + + /// Loads the first mentions page for the current profile DID. + /// + /// The widget resets local paging state because `didUpdateWidget` can reuse + /// the same pane while the enclosing profile screen changes actors. + Future _loadInitial() async { + setState(() { + _isLoading = true; + _error = null; + _posts = const []; + _cursor = null; + _hasMore = true; + }); + + try { + final page = await widget.profileRepository.getActorMentions(actor: widget.actorDid, limit: 50); + if (!mounted) return; + setState(() { + _posts = page.posts; + _cursor = page.cursor; + _hasMore = page.cursor != null; + _isLoading = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _error = 'Failed to load mentions: $error'; + _isLoading = false; + }); + } + } + + /// Loads the next AppView search page and appends only unseen post URIs. + /// + /// Search results can shift between requests, so duplicate filtering happens + /// in the pane even though the repository ranks each page independently. + Future _loadMore() async { + if (_isLoadingMore || !_hasMore || _cursor == null) { + return; + } + setState(() => _isLoadingMore = true); + + try { + final page = await widget.profileRepository.getActorMentions(actor: widget.actorDid, cursor: _cursor, limit: 50); + if (!mounted) return; + final seen = _posts.map((post) => post.uri.toString()).toSet(); + setState(() { + _posts = [ + ..._posts, + for (final post in page.posts) + if (seen.add(post.uri.toString())) post, + ]; + _cursor = page.cursor; + _hasMore = page.cursor != null; + _isLoadingMore = false; + }); + } catch (error, stackTrace) { + log.d('ProfileMentionsPane: ignored load-more failure', error: error, stackTrace: stackTrace); + if (!mounted) return; + setState(() => _isLoadingMore = false); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const CustomScrollView( + key: PageStorageKey('profile-mentions-loading'), + slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))], + ); + } + + if (_error != null) { + return CustomScrollView( + key: const PageStorageKey('profile-mentions-error'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + const SizedBox(height: 12), + FilledButton(onPressed: _loadInitial, child: Text(context.l10n.buttonRetry)), + ], + ), + ), + ), + ], + ); + } + + if (_posts.isEmpty) { + return const CustomScrollView( + key: PageStorageKey('profile-mentions-empty'), + slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('No mentions yet')))], + ); + } + + final accountDid = context.read().state.tokens?.did ?? ''; + return RefreshIndicator( + onRefresh: _loadInitial, + child: NotificationListener( + onNotification: (notification) { + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300) { + _loadMore(); + } + return false; + }, + child: ListView.builder( + key: const PageStorageKey('profile-mentions-list'), + itemCount: _posts.length + (_isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _posts.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return PostCardWithActions( + feedViewPost: FeedViewPost(post: _posts[index]), + accountDid: accountDid, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, + ); + }, + ), + ), + ); + } +} diff --git a/test/features/profile/data/profile_repository_mentions_test.dart b/test/features/profile/data/profile_repository_mentions_test.dart new file mode 100644 index 0000000..0308899 --- /dev/null +++ b/test/features/profile/data/profile_repository_mentions_test.dart @@ -0,0 +1,142 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/search_posts.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:poptart_core/poptart_core.dart'; + +import '../../../helpers/test_bluesky_client.dart'; + +void main() { + late AppDatabase database; + + setUp(() async { + database = AppDatabase(executor: NativeDatabase.memory()); + }); + + tearDown(() async { + await database.close(); + }); + + test('getActorMentions searches by DID and ranks useful mentions first', () async { + const actorDid = 'did:plc:mentioned'; + final capturedQueries = >[]; + final bluesky = testBluesky( + getClient: (url, {headers}) async { + expect(url.pathSegments.last, 'app.bsky.feed.searchPosts'); + capturedQueries.add(url.queryParameters); + return jsonResponse( + url, + 'GET', + FeedSearchPostsOutput( + cursor: 'next', + posts: [ + _post( + 'at://did:plc:reply/app.bsky.feed.post/reply', + authorDid: 'did:plc:reply', + text: 'reply mention', + indexedAt: DateTime.utc(2026, 5, 23, 12), + reply: true, + likeCount: 100, + ), + _post( + 'at://did:plc:top/app.bsky.feed.post/top', + authorDid: 'did:plc:top', + text: 'good mention', + indexedAt: DateTime.utc(2026, 5, 23, 10), + likeCount: 4, + ), + _post( + 'at://did:plc:multi/app.bsky.feed.post/multi', + authorDid: 'did:plc:multi', + text: 'many mentions', + indexedAt: DateTime.utc(2026, 5, 23, 11), + mentionCount: 5, + likeCount: 2, + ), + ], + ).toJson(), + ); + }, + ); + final repository = ProfileRepository(database: database, bluesky: bluesky); + + final page = await repository.getActorMentions(actor: actorDid, limit: 50); + + expect(capturedQueries.single['mentions'], actorDid); + expect(capturedQueries.single['q'], '*'); + expect(capturedQueries.single['sort'], 'latest'); + expect(page.cursor, 'next'); + expect(page.posts.map((post) => post.uri.toString()), [ + 'at://did:plc:top/app.bsky.feed.post/top', + 'at://did:plc:multi/app.bsky.feed.post/multi', + 'at://did:plc:reply/app.bsky.feed.post/reply', + ]); + }); + + test('getActorMentions caps repeated authors per page', () async { + final bluesky = testBluesky( + getClient: (url, {headers}) async { + return jsonResponse( + url, + 'GET', + FeedSearchPostsOutput( + posts: [ + _post('at://did:plc:author/app.bsky.feed.post/1', authorDid: 'did:plc:author', text: 'one'), + _post('at://did:plc:author/app.bsky.feed.post/2', authorDid: 'did:plc:author', text: 'two'), + _post('at://did:plc:author/app.bsky.feed.post/3', authorDid: 'did:plc:author', text: 'three'), + ], + ).toJson(), + ); + }, + ); + final repository = ProfileRepository(database: database, bluesky: bluesky); + + final page = await repository.getActorMentions(actor: 'did:plc:mentioned'); + + expect(page.posts.map((post) => post.uri.toString()), [ + 'at://did:plc:author/app.bsky.feed.post/1', + 'at://did:plc:author/app.bsky.feed.post/2', + ]); + }); +} + +PostView _post( + String uri, { + required String authorDid, + required String text, + DateTime? indexedAt, + bool reply = false, + int mentionCount = 1, + int likeCount = 0, +}) { + final postTime = indexedAt ?? DateTime.utc(2026, 5, 23, 12); + return PostView( + uri: AtUri(uri), + cid: 'cid-$uri', + author: ProfileViewBasic(did: authorDid, handle: '$authorDid.example'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': text, + 'createdAt': postTime.toIso8601String(), + if (reply) + 'reply': { + 'root': {'uri': 'at://did:plc:root/app.bsky.feed.post/root', 'cid': 'cid-root'}, + 'parent': {'uri': 'at://did:plc:root/app.bsky.feed.post/root', 'cid': 'cid-root'}, + }, + 'facets': [ + for (var i = 0; i < mentionCount; i++) + { + 'features': [ + {r'$type': 'app.bsky.richtext.facet#mention', 'did': 'did:plc:mentioned'}, + ], + 'index': {'byteStart': 0, 'byteEnd': 1}, + }, + ], + }, + indexedAt: postTime, + likeCount: likeCount, + ); +} diff --git a/test/features/profile/presentation/profile_mentions_pane_test.dart b/test/features/profile/presentation/profile_mentions_pane_test.dart new file mode 100644 index 0000000..c1e428a --- /dev/null +++ b/test/features/profile/presentation/profile_mentions_pane_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/profile/presentation/widgets/profile_mentions_pane.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockProfileRepository extends Mock implements ProfileRepository {} + +void main() { + testWidgets('loads mentions for the actor DID and renders the empty state', (tester) async { + final repository = MockProfileRepository(); + when( + () => repository.getActorMentions(actor: 'did:plc:mentioned', limit: 50), + ).thenAnswer((_) async => const ProfileMentionsResult(posts: [])); + + await tester.pumpWidget( + MaterialApp( + home: ProfileMentionsPane(actorDid: 'did:plc:mentioned', profileRepository: repository), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No mentions yet'), findsOneWidget); + verify(() => repository.getActorMentions(actor: 'did:plc:mentioned', limit: 50)).called(1); + }); +}