diff --git a/lib/features/notifications/data/notification_repository.dart b/lib/features/notifications/data/notification_repository.dart index 3fcdd4e..cf49f33 100644 --- a/lib/features/notifications/data/notification_repository.dart +++ b/lib/features/notifications/data/notification_repository.dart @@ -123,7 +123,7 @@ class NotificationRepository { if (senderDid != null && senderDid.trim().isNotEmpty && notification.author.did != senderDid) { continue; } - if (reason != null && reason.trim().isNotEmpty && _reasonName(notification) != reason) { + if (reason != null && reason.trim().isNotEmpty && !_reasonMatches(notification, reason)) { continue; } return notification; @@ -204,6 +204,26 @@ class NotificationRepository { } return 'unknown'; } + + bool _reasonMatches(Notification notification, String reason) { + final normalizedPayloadReason = reason.trim(); + if (normalizedPayloadReason.isEmpty) { + return true; + } + + final notificationReason = _reasonName(notification); + if (notificationReason == normalizedPayloadReason) { + return true; + } + + final familyReason = switch (notificationReason) { + 'like-via-repost' => 'like', + 'repost-via-repost' => 'repost', + _ => notificationReason, + }; + + return familyReason == normalizedPayloadReason; + } } class NotificationListResult { diff --git a/lib/features/notifications/domain/notification_local_mappers.dart b/lib/features/notifications/domain/notification_local_mappers.dart index a9e29aa..26e13f9 100644 --- a/lib/features/notifications/domain/notification_local_mappers.dart +++ b/lib/features/notifications/domain/notification_local_mappers.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:bluesky/app_bsky_notification_listnotifications.dart'; import 'package:crypto/crypto.dart'; import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; +import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; class NotificationPayloadCodec { static String encode(NotificationDeepLink deepLink) { @@ -43,7 +44,7 @@ class NotificationLocalMapper { return null; } - final deepLink = _deepLinkForNotification(notification); + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); if (deepLink == null) { return null; } @@ -51,58 +52,12 @@ class NotificationLocalMapper { return LocalNotificationRequest( notificationId: _stableNotificationId(notification.uri.toString()), title: _titleForNotification(notification), - body: _bodyForReason(notification.reason), - reasonFamily: _reasonFamilyForReason(notification.reason), + body: NotificationReasonUtils.localNotificationBodyForReason(notification.reason), + reasonFamily: NotificationReasonUtils.reasonFamilyForReason(notification.reason), deepLink: deepLink, ); } - static NotificationReasonFamily _reasonFamilyForReason(NotificationReason reason) { - final known = reason.knownValue; - if (known == null) { - return NotificationReasonFamily.misc; - } - - switch (known) { - case KnownNotificationReason.mention: - return NotificationReasonFamily.mentions; - case KnownNotificationReason.reply: - case KnownNotificationReason.quote: - return NotificationReasonFamily.replies; - case KnownNotificationReason.follow: - return NotificationReasonFamily.follows; - case KnownNotificationReason.like: - case KnownNotificationReason.repost: - return NotificationReasonFamily.likes; - default: - return NotificationReasonFamily.misc; - } - } - - static NotificationDeepLink? _deepLinkForNotification(Notification notification) { - final knownReason = notification.reason.knownValue; - - if (knownReason == KnownNotificationReason.follow) { - final actor = notification.author.did.trim(); - if (actor.isEmpty) { - return null; - } - return NotificationDeepLink( - route: '/profile/${Uri.encodeComponent(actor)}', - navigationMode: NotificationTapNavigationMode.go, - ); - } - - final useReasonSubject = - knownReason == KnownNotificationReason.like || knownReason == KnownNotificationReason.repost; - final targetUri = (useReasonSubject ? notification.reasonSubject : null) ?? notification.uri; - - return NotificationDeepLink( - route: '/post?uri=${Uri.encodeQueryComponent(targetUri.toString())}', - navigationMode: NotificationTapNavigationMode.push, - ); - } - static String _titleForNotification(Notification notification) { final displayName = notification.author.displayName?.trim(); if (displayName != null && displayName.isNotEmpty) { @@ -112,26 +67,6 @@ class NotificationLocalMapper { return handle.isEmpty ? 'New notification' : handle; } - static String _bodyForReason(NotificationReason reason) { - final known = reason.knownValue; - switch (known) { - case KnownNotificationReason.like: - return 'liked your post'; - case KnownNotificationReason.repost: - return 'reposted your post'; - case KnownNotificationReason.reply: - return 'replied to your post'; - case KnownNotificationReason.follow: - return 'followed you'; - case KnownNotificationReason.mention: - return 'mentioned you'; - case KnownNotificationReason.quote: - return 'quoted your post'; - default: - return 'sent a notification'; - } - } - static int _stableNotificationId(String value) { final digest = sha1.convert(utf8.encode(value)).bytes; final id = (digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]; diff --git a/lib/features/notifications/domain/notification_reason_utils.dart b/lib/features/notifications/domain/notification_reason_utils.dart new file mode 100644 index 0000000..5a37cfc --- /dev/null +++ b/lib/features/notifications/domain/notification_reason_utils.dart @@ -0,0 +1,176 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +abstract final class NotificationReasonUtils { + static bool isProfileNavigationReason(bsky.NotificationReason reason) { + if (!reason.isKnownValue) { + return false; + } + + switch (reason.knownValue) { + case bsky.KnownNotificationReason.follow: + case bsky.KnownNotificationReason.verified: + case bsky.KnownNotificationReason.unverified: + case bsky.KnownNotificationReason.contactMatch: + return true; + default: + return false; + } + } + + static bool isEngagementReason(bsky.NotificationReason reason) { + if (!reason.isKnownValue) { + return false; + } + + switch (reason.knownValue) { + case bsky.KnownNotificationReason.like: + case bsky.KnownNotificationReason.repost: + case bsky.KnownNotificationReason.likeViaRepost: + case bsky.KnownNotificationReason.repostViaRepost: + return true; + default: + return false; + } + } + + static String summaryTextForReason(bsky.NotificationReason reason) { + if (!reason.isKnownValue) { + return 'interacted with you'; + } + + switch (reason.knownValue) { + case bsky.KnownNotificationReason.like: + return 'liked your post'; + case bsky.KnownNotificationReason.repost: + return 'reposted your post'; + case bsky.KnownNotificationReason.likeViaRepost: + return 'liked your repost'; + case bsky.KnownNotificationReason.repostViaRepost: + return 'reposted your repost'; + case bsky.KnownNotificationReason.follow: + return 'followed you'; + case bsky.KnownNotificationReason.mention: + return 'mentioned you'; + case bsky.KnownNotificationReason.reply: + return 'replied to your post'; + case bsky.KnownNotificationReason.quote: + return 'quoted your post'; + case bsky.KnownNotificationReason.starterpackJoined: + return 'joined via your starter pack'; + case bsky.KnownNotificationReason.verified: + return 'verified your account'; + case bsky.KnownNotificationReason.unverified: + return 'removed your verification'; + case bsky.KnownNotificationReason.subscribedPost: + return 'posted a new update'; + case bsky.KnownNotificationReason.contactMatch: + return 'joined from your contacts'; + default: + return 'interacted with you'; + } + } + + static String localNotificationBodyForReason(bsky.NotificationReason reason) { + final summary = summaryTextForReason(reason); + return summary == 'interacted with you' ? 'sent a notification' : summary; + } + + static NotificationReasonFamily reasonFamilyForReason(bsky.NotificationReason reason) { + final known = reason.knownValue; + if (known == null) { + return NotificationReasonFamily.misc; + } + + switch (known) { + case bsky.KnownNotificationReason.mention: + return NotificationReasonFamily.mentions; + case bsky.KnownNotificationReason.reply: + case bsky.KnownNotificationReason.quote: + case bsky.KnownNotificationReason.subscribedPost: + return NotificationReasonFamily.replies; + case bsky.KnownNotificationReason.follow: + case bsky.KnownNotificationReason.contactMatch: + case bsky.KnownNotificationReason.starterpackJoined: + return NotificationReasonFamily.follows; + case bsky.KnownNotificationReason.like: + case bsky.KnownNotificationReason.repost: + case bsky.KnownNotificationReason.likeViaRepost: + case bsky.KnownNotificationReason.repostViaRepost: + return NotificationReasonFamily.likes; + case bsky.KnownNotificationReason.verified: + case bsky.KnownNotificationReason.unverified: + return NotificationReasonFamily.misc; + } + } + + static NotificationDeepLink? deepLinkForNotification(bsky.Notification notification) { + if (notification.reason.knownValue == bsky.KnownNotificationReason.starterpackJoined) { + final starterPackUri = notification.reasonSubject ?? _extractSubjectUri(notification.record); + if (starterPackUri != null) { + return NotificationDeepLink( + route: '/starter-pack?uri=${Uri.encodeQueryComponent(starterPackUri.toString())}', + navigationMode: NotificationTapNavigationMode.push, + ); + } + // Fallback to actor profile if the payload is missing starter pack context. + } + + if (isProfileNavigationReason(notification.reason)) { + final actor = notification.author.did.trim(); + if (actor.isEmpty) { + return null; + } + return NotificationDeepLink( + route: '/profile/${Uri.encodeComponent(actor)}', + navigationMode: NotificationTapNavigationMode.go, + ); + } + + final targetUri = deepLinkTargetUri(notification); + return NotificationDeepLink( + route: '/post?uri=${Uri.encodeQueryComponent(targetUri.toString())}', + navigationMode: NotificationTapNavigationMode.push, + ); + } + + static AtUri deepLinkTargetUri(bsky.Notification notification) { + if (!isEngagementReason(notification.reason)) { + return notification.uri; + } + + final reasonSubject = notification.reasonSubject; + if (_isPostUri(reasonSubject)) { + return reasonSubject!; + } + + final recordSubject = _extractSubjectUri(notification.record); + if (_isPostUri(recordSubject)) { + return recordSubject!; + } + + return reasonSubject ?? recordSubject ?? notification.uri; + } + + static bool _isPostUri(AtUri? uri) => uri?.collection.toString() == 'app.bsky.feed.post'; + + static AtUri? _extractSubjectUri(Map record) { + final rawSubject = record['subject']; + final uriValue = switch (rawSubject) { + final Map subjectMap => subjectMap['uri'], + final String value => value, + _ => null, + }; + + if (uriValue is! String || uriValue.trim().isEmpty) { + return null; + } + + try { + return AtUri.parse(uriValue.trim()); + } catch (_) { + return null; + } + } +} diff --git a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart index 7df8e3c..2e442aa 100644 --- a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart @@ -2,9 +2,11 @@ import 'package:bluesky/app_bsky_actor_defs.dart' as actor; import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart' hide Notification; +import 'package:go_router/go_router.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; -import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; +import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; +import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; @@ -184,36 +186,11 @@ class GroupedNotificationListItem extends StatelessWidget { } String _getReasonText(bsky.Notification notification) { - final reason = notification.reason; - - if (reason.isKnownValue) { - switch (reason.knownValue) { - case bsky.KnownNotificationReason.like: - return 'liked your post'; - case bsky.KnownNotificationReason.repost: - return 'reposted your post'; - case bsky.KnownNotificationReason.follow: - return 'followed you'; - case bsky.KnownNotificationReason.mention: - return 'mentioned you'; - case bsky.KnownNotificationReason.reply: - return 'replied to your post'; - case bsky.KnownNotificationReason.quote: - return 'quoted your post'; - default: - return 'interacted with you'; - } - } - - return 'interacted with you'; + return NotificationReasonUtils.summaryTextForReason(notification.reason); } bool _shouldShowPreview(bsky.Notification notification) { - final reason = notification.reason; - if (reason.isKnownValue) { - return reason.knownValue != bsky.KnownNotificationReason.follow; - } - return false; + return !NotificationReasonUtils.isProfileNavigationReason(notification.reason); } Widget _buildPreview(BuildContext context, ThemeData theme, bsky.Notification notification) { @@ -249,18 +226,10 @@ class GroupedNotificationListItem extends StatelessWidget { void _onTap(BuildContext context) { final notification = group.latest; - final reason = notification.reason; - - if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { - navigateToProfile(context, notification.author.did); + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); + if (deepLink == null) { return; } - - final isLikeOrRepost = - reason.isKnownValue && - (reason.knownValue == bsky.KnownNotificationReason.like || - reason.knownValue == bsky.KnownNotificationReason.repost); - final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; - navigateToPost(context, uri.toString()); + NotificationDeepLinkNavigator.navigate(GoRouter.of(context), deepLink); } } diff --git a/lib/features/notifications/presentation/widgets/notification_list_item.dart b/lib/features/notifications/presentation/widgets/notification_list_item.dart index 2299ee2..9e4bfe4 100644 --- a/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -5,7 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; -import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; +import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; +import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; @@ -119,28 +120,7 @@ class NotificationListItem extends StatelessWidget { } String _getReasonText() { - final reason = notification.reason; - - if (reason.isKnownValue) { - switch (reason.knownValue) { - case bsky.KnownNotificationReason.like: - return 'liked your post'; - case bsky.KnownNotificationReason.repost: - return 'reposted your post'; - case bsky.KnownNotificationReason.follow: - return 'followed you'; - case bsky.KnownNotificationReason.mention: - return 'mentioned you'; - case bsky.KnownNotificationReason.reply: - return 'replied to your post'; - case bsky.KnownNotificationReason.quote: - return 'quoted your post'; - default: - return 'interacted with you'; - } - } - - return 'interacted with you'; + return NotificationReasonUtils.summaryTextForReason(notification.reason); } Widget _buildTime(ThemeData theme) { @@ -151,11 +131,7 @@ class NotificationListItem extends StatelessWidget { } bool get _shouldShowPreview { - final reason = notification.reason; - if (reason.isKnownValue) { - return reason.knownValue != bsky.KnownNotificationReason.follow; - } - return false; + return !NotificationReasonUtils.isProfileNavigationReason(notification.reason); } Widget _buildPreview(BuildContext context, ThemeData theme) { @@ -191,17 +167,10 @@ class NotificationListItem extends StatelessWidget { } void _onTap(BuildContext context) { - final reason = notification.reason; - - if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { - navigateToProfile(context, notification.author.did); - } else { - 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())}'); + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); + if (deepLink == null) { + return; } + NotificationDeepLinkNavigator.navigate(GoRouter.of(context), deepLink); } } diff --git a/lib/shared/presentation/helpers/notification_icon_mapper.dart b/lib/shared/presentation/helpers/notification_icon_mapper.dart index e7e3a3d..9d3aae2 100644 --- a/lib/shared/presentation/helpers/notification_icon_mapper.dart +++ b/lib/shared/presentation/helpers/notification_icon_mapper.dart @@ -21,24 +21,34 @@ abstract final class NotificationIconMapper { switch (reason.knownValue) { case bsky.KnownNotificationReason.like: + case bsky.KnownNotificationReason.likeViaRepost: return NotificationIconStyle( backgroundColor: colorScheme.error.withValues(alpha: 0.1), iconColor: colorScheme.error, icon: Icons.favorite, ); case bsky.KnownNotificationReason.repost: + case bsky.KnownNotificationReason.repostViaRepost: return NotificationIconStyle( backgroundColor: Colors.green.withValues(alpha: 0.1), iconColor: Colors.green, icon: Icons.repeat, ); case bsky.KnownNotificationReason.follow: + case bsky.KnownNotificationReason.contactMatch: return NotificationIconStyle( backgroundColor: colorScheme.primary.withValues(alpha: 0.1), iconColor: colorScheme.primary, icon: Icons.person_add, ); + case bsky.KnownNotificationReason.starterpackJoined: + return NotificationIconStyle( + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), + iconColor: colorScheme.primary, + icon: Icons.group_add, + ); case bsky.KnownNotificationReason.reply: + case bsky.KnownNotificationReason.subscribedPost: return NotificationIconStyle( backgroundColor: colorScheme.secondary.withValues(alpha: 0.1), iconColor: colorScheme.secondary, @@ -56,6 +66,18 @@ abstract final class NotificationIconMapper { iconColor: Colors.purple, icon: Icons.format_quote, ); + case bsky.KnownNotificationReason.verified: + return NotificationIconStyle( + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), + iconColor: colorScheme.primary, + icon: Icons.verified, + ); + case bsky.KnownNotificationReason.unverified: + return NotificationIconStyle( + backgroundColor: colorScheme.error.withValues(alpha: 0.1), + iconColor: colorScheme.error, + icon: Icons.gpp_bad, + ); default: return NotificationIconStyle( backgroundColor: colorScheme.surfaceContainerHighest, diff --git a/test/features/notifications/domain/notification_local_mappers_test.dart b/test/features/notifications/domain/notification_local_mappers_test.dart index 933958a..57df74c 100644 --- a/test/features/notifications/domain/notification_local_mappers_test.dart +++ b/test/features/notifications/domain/notification_local_mappers_test.dart @@ -46,6 +46,50 @@ void main() { expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); }); + + test('maps like-via-repost notifications to post route using reasonSubject', () { + final reasonSubject = AtUri.parse('at://did:plc:target/app.bsky.feed.post/reposted-post'); + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.like/def'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.likeViaRepost), + reasonSubject: reasonSubject, + record: const {}, + isRead: false, + indexedAt: DateTime.utc(2026, 5, 1, 12), + ); + + final request = NotificationLocalMapper.requestFromNotification(notification); + + expect(request, isNotNull); + expect(request!.reasonFamily, NotificationReasonFamily.likes); + expect(request.body, 'liked your repost'); + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); + expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); + }); + + test('maps starterpack-joined notifications to starter pack detail route', () { + final starterPackUri = AtUri.parse('at://did:plc:author/app.bsky.graph.starterpack/sp1'); + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.graph.starterpackjoin/abc'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.starterpackJoined), + reasonSubject: starterPackUri, + record: const {}, + isRead: false, + indexedAt: DateTime.utc(2026, 5, 1, 12), + ); + + final request = NotificationLocalMapper.requestFromNotification(notification); + + expect(request, isNotNull); + expect(request!.reasonFamily, NotificationReasonFamily.follows); + expect(request.body, 'joined via your starter pack'); + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); + expect(request.deepLink.route, '/starter-pack?uri=${Uri.encodeQueryComponent(starterPackUri.toString())}'); + }); }); group('NotificationPayloadCodec', () { diff --git a/test/features/notifications/domain/notification_reason_utils_test.dart b/test/features/notifications/domain/notification_reason_utils_test.dart new file mode 100644 index 0000000..5c6a779 --- /dev/null +++ b/test/features/notifications/domain/notification_reason_utils_test.dart @@ -0,0 +1,30 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; + +void main() { + group('NotificationReasonUtils', () { + test('provides explicit summary copy for all known notification reasons', () { + final expectedTextByReason = { + bsky.KnownNotificationReason.like: 'liked your post', + bsky.KnownNotificationReason.repost: 'reposted your post', + bsky.KnownNotificationReason.follow: 'followed you', + bsky.KnownNotificationReason.mention: 'mentioned you', + bsky.KnownNotificationReason.reply: 'replied to your post', + bsky.KnownNotificationReason.quote: 'quoted your post', + bsky.KnownNotificationReason.starterpackJoined: 'joined via your starter pack', + bsky.KnownNotificationReason.verified: 'verified your account', + bsky.KnownNotificationReason.unverified: 'removed your verification', + bsky.KnownNotificationReason.likeViaRepost: 'liked your repost', + bsky.KnownNotificationReason.repostViaRepost: 'reposted your repost', + bsky.KnownNotificationReason.subscribedPost: 'posted a new update', + bsky.KnownNotificationReason.contactMatch: 'joined from your contacts', + }; + + for (final entry in expectedTextByReason.entries) { + final text = NotificationReasonUtils.summaryTextForReason(bsky.NotificationReason.knownValue(data: entry.key)); + expect(text, entry.value, reason: 'Unexpected summary for ${entry.key.value}'); + } + }); + }); +} diff --git a/test/features/notifications/presentation/widgets/notification_list_item_test.dart b/test/features/notifications/presentation/widgets/notification_list_item_test.dart index 5c9bbd0..a62ae02 100644 --- a/test/features/notifications/presentation/widgets/notification_list_item_test.dart +++ b/test/features/notifications/presentation/widgets/notification_list_item_test.dart @@ -123,6 +123,77 @@ void main() { expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); }); + testWidgets('like-via-repost notification uses reasonSubject to navigate to post', (tester) async { + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post789'); + final notification = _makeNotification( + reason: bsky.KnownNotificationReason.likeViaRepost, + uri: AtUri.parse('at://did:plc:liker/app.bsky.feed.like/like-via-repost'), + 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('starterpack-joined notification navigates to starter pack detail route', (tester) async { + final starterPackUri = AtUri.parse('at://did:plc:author/app.bsky.graph.starterpack/sp1'); + final notification = _makeNotification( + reason: bsky.KnownNotificationReason.starterpackJoined, + reasonSubject: starterPackUri, + ); + String? pushedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), + ), + GoRoute( + path: '/starter-pack', + builder: (context, state) { + pushedRoute = state.uri.toString(); + return const Scaffold(body: Text('starter pack detail')); + }, + ), + ], + ); + + 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, '/starter-pack'); + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), starterPackUri.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( diff --git a/test/shared/presentation/helpers/notification_icon_mapper_test.dart b/test/shared/presentation/helpers/notification_icon_mapper_test.dart index 137bf92..159a5e3 100644 --- a/test/shared/presentation/helpers/notification_icon_mapper_test.dart +++ b/test/shared/presentation/helpers/notification_icon_mapper_test.dart @@ -27,6 +27,26 @@ void main() { expect(style.iconColor, colorScheme.primary); }); + test('maps like-via-repost reason to favorite icon and error color', () { + final style = NotificationIconMapper.map( + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.likeViaRepost), + colorScheme: colorScheme, + ); + + expect(style.icon, Icons.favorite); + expect(style.iconColor, colorScheme.error); + }); + + test('maps verified reason to verified icon and primary color', () { + final style = NotificationIconMapper.map( + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.verified), + colorScheme: colorScheme, + ); + + expect(style.icon, Icons.verified); + expect(style.iconColor, colorScheme.primary); + }); + test('maps quote reason to quote icon and purple color', () { final style = NotificationIconMapper.map( reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.quote),