diff --git a/docs/BUGS.md b/docs/BUGS.md index 5f4ffd2..ae63f75 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -8,9 +8,9 @@ updated: 2026-03-17 - [x] [1. Post Thread Screen](#1-post-thread-screen) - [x] [2. Post Tap Navigation](#2-post-tap-navigation) - [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) -- [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) -- [ ] [5. Notification Tap Navigation](#5-notification-tap-navigation) -- [ ] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) +- [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) +- [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) +- [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) - [ ] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) - [ ] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) diff --git a/lib/features/feed/cubit/post_action_cache.dart b/lib/features/feed/cubit/post_action_cache.dart new file mode 100644 index 0000000..6c0b38b --- /dev/null +++ b/lib/features/feed/cubit/post_action_cache.dart @@ -0,0 +1,43 @@ +import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; + +/// In-memory cache that preserves optimistic like/repost state across +/// [PostActionCubit] recreations (e.g. after scroll recycling). +/// +/// Keyed by post URI. Transient fields (loading, error) are never cached. +class PostActionCache { + final Map _cache = {}; + + CachedPostAction? read(String postUri) => _cache[postUri]; + + /// Persists the settled state of [state] into the cache. + /// No-ops while either loading flag is set. + void write(PostActionState state) { + if (state.isLoadingLike || state.isLoadingRepost) return; + _cache[state.postUri] = CachedPostAction( + isLiked: state.isLiked, + isReposted: state.isReposted, + likeCount: state.likeCount, + repostCount: state.repostCount, + likeUri: state.likeUri, + repostUri: state.repostUri, + ); + } +} + +class CachedPostAction { + const CachedPostAction({ + required this.isLiked, + required this.isReposted, + required this.likeCount, + required this.repostCount, + this.likeUri, + this.repostUri, + }); + + final bool isLiked; + final bool isReposted; + final int likeCount; + final int repostCount; + final String? likeUri; + final String? repostUri; +} diff --git a/lib/features/feed/cubit/post_action_cubit.dart b/lib/features/feed/cubit/post_action_cubit.dart index f6a6da0..82017d9 100644 --- a/lib/features/feed/cubit/post_action_cubit.dart +++ b/lib/features/feed/cubit/post_action_cubit.dart @@ -2,6 +2,7 @@ import 'package:atproto_core/atproto_core.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/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; class PostActionState extends Equatable { @@ -80,10 +81,12 @@ class PostActionCubit extends Cubit { int repostCount = 0, String? likeUri, String? repostUri, + PostActionCache? cache, }) : _postActionRepository = postActionRepository, _postCid = postCid, + _cache = cache, super( - PostActionState( + _buildInitialState( postUri: postUri, isLiked: isLiked, isReposted: isReposted, @@ -91,11 +94,48 @@ class PostActionCubit extends Cubit { repostCount: repostCount, likeUri: likeUri, repostUri: repostUri, + cache: cache, ), ); final PostActionRepository _postActionRepository; final String _postCid; + final PostActionCache? _cache; + + static PostActionState _buildInitialState({ + required String postUri, + required bool isLiked, + required bool isReposted, + required int likeCount, + required int repostCount, + String? likeUri, + String? repostUri, + PostActionCache? cache, + }) { + final cached = cache?.read(postUri); + if (cached != null) { + return PostActionState( + postUri: postUri, + isLiked: cached.isLiked, + isReposted: cached.isReposted, + likeCount: cached.likeCount, + repostCount: cached.repostCount, + likeUri: cached.likeUri, + repostUri: cached.repostUri, + ); + } + return PostActionState( + postUri: postUri, + isLiked: isLiked, + isReposted: isReposted, + likeCount: likeCount, + repostCount: repostCount, + likeUri: likeUri, + repostUri: repostUri, + ); + } + + void _persistToCache() => _cache?.write(state); Future toggleLike() async { if (state.isLoadingLike) return; @@ -137,6 +177,8 @@ class PostActionCubit extends Cubit { error: 'Failed to ${wasLiked ? 'unlike' : 'like'} post', ), ); + } finally { + _persistToCache(); } } @@ -180,6 +222,8 @@ class PostActionCubit extends Cubit { error: 'Failed to ${wasReposted ? 'unrepost' : 'repost'} post', ), ); + } finally { + _persistToCache(); } } diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart index 36f5575..955b621 100644 --- a/lib/features/feed/presentation/widgets/post_card.dart +++ b/lib/features/feed/presentation/widgets/post_card.dart @@ -51,10 +51,7 @@ class PostCard extends StatelessWidget { ), ), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: actionBar ?? _buildActions(context), - ), + Padding(padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: actionBar ?? _buildActions(context)), ], ), ); @@ -357,7 +354,7 @@ class PostCard extends StatelessWidget { onTap: () { final router = GoRouter.maybeOf(context); if (router != null) { - router.push('/profile/view?actor=${Uri.encodeQueryComponent(quoted.author.did)}'); + router.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); } }, borderRadius: BorderRadius.circular(16), diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index c56aa8e..33bd782 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +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/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -28,8 +29,8 @@ class PostCardWithActions extends StatelessWidget { final viewer = post.viewer; return BlocProvider( - create: (_) => PostActionCubit( - postActionRepository: context.read(), + create: (ctx) => PostActionCubit( + postActionRepository: ctx.read(), postUri: post.uri.toString(), postCid: post.cid, isLiked: viewer?.like != null, @@ -38,6 +39,7 @@ class PostCardWithActions extends StatelessWidget { repostCount: post.repostCount ?? 0, likeUri: viewer?.like?.toString(), repostUri: viewer?.repost?.toString(), + cache: ctx.read(), ), child: _PostCardWithActionsContent(feedViewPost: feedViewPost, accountDid: accountDid), ); diff --git a/lib/features/notifications/presentation/widgets/notification_list_item.dart b/lib/features/notifications/presentation/widgets/notification_list_item.dart index d04f2f3..1887a1e 100644 --- a/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -259,7 +259,11 @@ class NotificationListItem extends StatelessWidget { if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { context.push('/profile/view?actor=${notification.author.did}'); } else { - final uri = notification.uri; + final isLikeOrRepost = + reason.isKnownValue && + (reason.knownValue == bsky.KnownNotificationReason.like || + reason.knownValue == bsky.KnownNotificationReason.repost); + final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); } } diff --git a/lib/main.dart b/lib/main.dart index 62acfa3..6550d55 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; @@ -152,6 +153,7 @@ class _LazuriteAppState extends State { RepositoryProvider.value(value: feedRepository), RepositoryProvider.value(value: searchRepository), RepositoryProvider.value(value: postActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider.value(value: profileActionRepository), RepositoryProvider.value(value: bluesky), RepositoryProvider.value(value: widget.database), diff --git a/test/features/feed/cubit/post_action_cache_test.dart b/test/features/feed/cubit/post_action_cache_test.dart new file mode 100644 index 0000000..bd153df --- /dev/null +++ b/test/features/feed/cubit/post_action_cache_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; + +void main() { + const postUri = 'at://did:plc:test/app.bsky.feed.post/abc'; + const likeUri = 'at://did:plc:test/app.bsky.feed.like/like1'; + const repostUri = 'at://did:plc:test/app.bsky.feed.repost/rp1'; + + group('PostActionCache', () { + test('read returns null when nothing cached', () { + final cache = PostActionCache(); + expect(cache.read(postUri), isNull); + }); + + test('write then read returns cached values', () { + final cache = PostActionCache(); + const state = PostActionState( + postUri: postUri, + isLiked: true, + isReposted: false, + likeCount: 3, + repostCount: 1, + likeUri: likeUri, + repostUri: null, + ); + + cache.write(state); + final cached = cache.read(postUri); + + expect(cached, isNotNull); + expect(cached!.isLiked, isTrue); + expect(cached.isReposted, isFalse); + expect(cached.likeCount, 3); + expect(cached.repostCount, 1); + expect(cached.likeUri, likeUri); + expect(cached.repostUri, isNull); + }); + + test('write ignores states with isLoadingLike true', () { + final cache = PostActionCache(); + const state = PostActionState(postUri: postUri, isLiked: true, likeCount: 5, isLoadingLike: true); + + cache.write(state); + + expect(cache.read(postUri), isNull); + }); + + test('write ignores states with isLoadingRepost true', () { + final cache = PostActionCache(); + const state = PostActionState(postUri: postUri, isReposted: true, repostCount: 2, isLoadingRepost: true); + + cache.write(state); + + expect(cache.read(postUri), isNull); + }); + + test('write overwrites existing entry', () { + final cache = PostActionCache(); + + cache.write(const PostActionState(postUri: postUri, isLiked: false, likeCount: 0)); + cache.write(const PostActionState(postUri: postUri, isLiked: true, likeCount: 1, likeUri: likeUri)); + + final cached = cache.read(postUri); + expect(cached!.isLiked, isTrue); + expect(cached.likeCount, 1); + expect(cached.likeUri, likeUri); + }); + + test('caches multiple posts independently', () { + const otherUri = 'at://did:plc:test/app.bsky.feed.post/other'; + final cache = PostActionCache(); + + cache.write(const PostActionState(postUri: postUri, isLiked: true, likeCount: 5, likeUri: likeUri)); + cache.write(const PostActionState(postUri: otherUri, isReposted: true, repostCount: 2, repostUri: repostUri)); + + expect(cache.read(postUri)!.isLiked, isTrue); + expect(cache.read(postUri)!.isReposted, isFalse); + expect(cache.read(otherUri)!.isReposted, isTrue); + expect(cache.read(otherUri)!.isLiked, isFalse); + }); + }); +} diff --git a/test/features/feed/cubit/post_action_cubit_test.dart b/test/features/feed/cubit/post_action_cubit_test.dart index 9d87cbf..2b53b0b 100644 --- a/test/features/feed/cubit/post_action_cubit_test.dart +++ b/test/features/feed/cubit/post_action_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; +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/data/post_action_repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -267,6 +268,150 @@ void main() { }); }); + group('PostActionCubit cache integration', () { + test('seeds initial state from cache when entry exists', () { + final cache = PostActionCache(); + cache.write( + const PostActionState( + postUri: testPostUri, + isLiked: true, + isReposted: true, + likeCount: 42, + repostCount: 7, + likeUri: testLikeUri, + repostUri: testRepostUri, + ), + ); + + final cubit = PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + // API says unliked with 0 counts — cache should win + isLiked: false, + likeCount: 0, + cache: cache, + ); + + expect(cubit.state.isLiked, isTrue); + expect(cubit.state.likeCount, 42); + expect(cubit.state.isReposted, isTrue); + expect(cubit.state.repostCount, 7); + expect(cubit.state.likeUri, testLikeUri); + expect(cubit.state.repostUri, testRepostUri); + }); + + test('uses API values when no cache entry exists', () { + final cache = PostActionCache(); + + final cubit = PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + isLiked: true, + likeCount: 5, + likeUri: testLikeUri, + cache: cache, + ); + + expect(cubit.state.isLiked, isTrue); + expect(cubit.state.likeCount, 5); + expect(cubit.state.likeUri, testLikeUri); + }); + + blocTest( + 'writes to cache after successful like', + build: () { + when( + () => mockRepository.likePost( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenAnswer((_) async => testLikeUri); + return PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + likeCount: 2, + cache: PostActionCache(), + ); + }, + act: (cubit) async { + await cubit.toggleLike(); + }, + verify: (cubit) { + // After settling, cache should reflect liked state. + // We verify by re-seeding a new cubit from the same cache. + // The cubit exposes its cache indirectly — just check final state. + expect(cubit.state.isLiked, isTrue); + expect(cubit.state.likeUri, testLikeUri); + expect(cubit.state.isLoadingLike, isFalse); + }, + ); + + blocTest( + 'writes rolled-back state to cache after like failure', + build: () { + when( + () => mockRepository.likePost( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenThrow(Exception('network')); + return PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + likeCount: 2, + cache: PostActionCache(), + ); + }, + act: (cubit) async { + await cubit.toggleLike(); + }, + verify: (cubit) { + expect(cubit.state.isLiked, isFalse); + expect(cubit.state.likeCount, 2); + expect(cubit.state.isLoadingLike, isFalse); + }, + ); + + test('new cubit seeded from cache reflects prior like action', () async { + final cache = PostActionCache(); + + when( + () => mockRepository.likePost( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenAnswer((_) async => testLikeUri); + + final cubit1 = PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + likeCount: 3, + cache: cache, + ); + await cubit1.toggleLike(); + await cubit1.close(); + + // Simulate widget recycling: new cubit with stale API data (isLiked=false). + final cubit2 = PostActionCubit( + postActionRepository: mockRepository, + postUri: testPostUri, + postCid: testPostCid, + isLiked: false, + likeCount: 3, // stale API count + cache: cache, + ); + + expect(cubit2.state.isLiked, isTrue); + expect(cubit2.state.likeCount, 4); + expect(cubit2.state.likeUri, testLikeUri); + }); + }); + group('PostActionState', () { test('props includes all fields', () { const state1 = PostActionState( diff --git a/test/features/feed/presentation/post_card_test.dart b/test/features/feed/presentation/post_card_test.dart index 513166a..4f2e46c 100644 --- a/test/features/feed/presentation/post_card_test.dart +++ b/test/features/feed/presentation/post_card_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_embed_external.dart'; +import 'package:bluesky/app_bsky_embed_record.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:bluesky/app_bsky_richtext_facet.dart'; @@ -27,7 +28,9 @@ FeedViewPost _makePost({String text = 'Hello'}) { void main() { Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { return MaterialApp( - home: Scaffold(body: PostCard(feedViewPost: post, onTap: onTap)), + home: Scaffold( + body: PostCard(feedViewPost: post, onTap: onTap), + ), ); } @@ -113,6 +116,61 @@ void main() { await tester.pump(); }); + testWidgets('tapping quoted post navigates to /post with quoted uri', (tester) async { + final quotedUri = AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'); + final record = FeedPostRecord(text: 'Main post', createdAt: DateTime.utc(2026, 3, 16)); + final quotedRecord = FeedPostRecord(text: 'Quoted text', createdAt: DateTime.utc(2026, 3, 15)); + final post = FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), + cid: 'cid-xyz', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 3, 16), + embed: UPostViewEmbed.embedRecordView( + data: EmbedRecordView( + record: UEmbedRecordViewRecord.embedRecordViewRecord( + data: EmbedRecordViewRecord( + uri: quotedUri, + cid: 'cid-quoted', + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), + value: quotedRecord.toJson(), + indexedAt: DateTime.utc(2026, 3, 15), + ), + ), + ), + ), + ), + ); + + String? pushedRoute; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), + ), + GoRoute( + path: '/post', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('post thread')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Quoted text', findRichText: true)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/post'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), quotedUri.toString()); + }); + testWidgets('tapping avatar navigates to author profile', (tester) async { final post = _makePost(); String? pushedRoute; diff --git a/test/features/notifications/presentation/widgets/notification_list_item_test.dart b/test/features/notifications/presentation/widgets/notification_list_item_test.dart new file mode 100644 index 0000000..1c3f8f0 --- /dev/null +++ b/test/features/notifications/presentation/widgets/notification_list_item_test.dart @@ -0,0 +1,194 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; + +bsky.Notification _makeNotification({required bsky.KnownNotificationReason reason, AtUri? uri, AtUri? reasonSubject}) { + return bsky.Notification( + uri: uri ?? AtUri.parse('at://did:plc:liker/app.bsky.feed.like/abc'), + cid: 'cid-abc', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: bsky.NotificationReason.knownValue(data: reason), + reasonSubject: reasonSubject, + record: const {}, + isRead: true, + indexedAt: DateTime.utc(2026, 3, 17), + ); +} + +void main() { + group('NotificationListItem tap navigation', () { + testWidgets('follow notification navigates to author profile', (tester) async { + final notification = _makeNotification(reason: bsky.KnownNotificationReason.follow); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/profile/view', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('profile')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NotificationListItem)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/profile/view'); + }); + + testWidgets('like notification uses reasonSubject to navigate to post', (tester) async { + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post123'); + final notification = _makeNotification( + reason: bsky.KnownNotificationReason.like, + uri: AtUri.parse('at://did:plc:liker/app.bsky.feed.like/abc'), + reasonSubject: postUri, + ); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/post', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('post thread')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NotificationListItem)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/post'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); + }); + + testWidgets('repost notification uses reasonSubject to navigate to post', (tester) async { + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post456'); + final notification = _makeNotification( + reason: bsky.KnownNotificationReason.repost, + uri: AtUri.parse('at://did:plc:reposter/app.bsky.feed.repost/repost1'), + reasonSubject: postUri, + ); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/post', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('post thread')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NotificationListItem)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/post'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); + }); + + testWidgets('like notification falls back to uri when reasonSubject is null', (tester) async { + final likeUri = AtUri.parse('at://did:plc:liker/app.bsky.feed.like/fallback'); + final notification = _makeNotification( + reason: bsky.KnownNotificationReason.like, + uri: likeUri, + reasonSubject: null, + ); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/post', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('post thread')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NotificationListItem)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/post'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), likeUri.toString()); + }); + + testWidgets('reply notification navigates to post using notification uri', (tester) async { + final replyUri = AtUri.parse('at://did:plc:replier/app.bsky.feed.post/reply1'); + final notification = _makeNotification(reason: bsky.KnownNotificationReason.reply, uri: replyUri); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/post', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('post thread')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NotificationListItem)); + await tester.pumpAndSettle(); + + expect(pushedRoute, isNotNull); + expect(Uri.parse(pushedRoute!).path, '/post'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), replyUri.toString()); + }); + }); +}