From 6d096400b5b188e4215530d30bee87d9f0a995d5 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 26 Jun 2026 14:39:39 -0400 Subject: [PATCH] refactor: new push notification support --- lib/src/core/l10n/app_localizations.dart | 72 ++++++++++++++ lib/src/core/l10n/app_localizations_en.dart | 99 +++++++++++++++++++ lib/src/core/l10n/intl_en.arb | 95 ++++++++++++++++++ .../notification_navigation.dart | 77 ++++++++++++++- .../push_notification_service.dart | 23 ++--- .../models/grouped_notification.dart | 42 +++----- .../ui/widgets/notification_item.dart | 80 +++++++++------ .../push_notification_service_test.dart | 76 +++++++++++++- .../models/grouped_notification_test.dart | 62 ++++++++++++ 9 files changed, 542 insertions(+), 84 deletions(-) diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index 58a20646..32751338 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -1390,6 +1390,78 @@ abstract class AppLocalizations { /// **'No notifications'** String get emptyNoNotifications; + /// Notification action text for likes on the viewer's post + /// + /// In en, this message translates to: + /// **'{count, plural, =0{liked your post} =1{and 1 other liked your post} other{and {count} others liked your post}}'** + String notificationLikedPost(int count); + + /// Notification action text for likes on the viewer's reply + /// + /// In en, this message translates to: + /// **'{count, plural, =0{liked your reply} =1{and 1 other liked your reply} other{and {count} others liked your reply}}'** + String notificationLikedReply(int count); + + /// Notification action text when someone liked content through the viewer's repost + /// + /// In en, this message translates to: + /// **'{count, plural, =0{liked your repost} =1{and 1 other liked your repost} other{and {count} others liked your repost}}'** + String notificationLikedRepost(int count); + + /// Notification action text for reposts of the viewer's post + /// + /// In en, this message translates to: + /// **'{count, plural, =0{reposted your post} =1{and 1 other reposted your post} other{and {count} others reposted your post}}'** + String notificationRepostedPost(int count); + + /// Notification action text for reposts of the viewer's reply + /// + /// In en, this message translates to: + /// **'{count, plural, =0{reposted your reply} =1{and 1 other reposted your reply} other{and {count} others reposted your reply}}'** + String notificationRepostedReply(int count); + + /// Notification action text when someone reposted content through the viewer's repost + /// + /// In en, this message translates to: + /// **'{count, plural, =0{reposted your repost} =1{and 1 other reposted your repost} other{and {count} others reposted your repost}}'** + String notificationRepostedRepost(int count); + + /// Notification action text for follows + /// + /// In en, this message translates to: + /// **'{count, plural, =0{followed you} =1{and 1 other followed you} other{and {count} others followed you}}'** + String notificationFollowed(int count); + + /// Notification action text for a follow-back + /// + /// In en, this message translates to: + /// **'followed you back'** + String get notificationFollowedBack; + + /// Notification action text for mentions + /// + /// In en, this message translates to: + /// **'mentioned you'** + String get notificationMentioned; + + /// Notification action text for replies to the viewer's post + /// + /// In en, this message translates to: + /// **'replied to your post'** + String get notificationRepliedPost; + + /// Notification action text for replies to the viewer's reply + /// + /// In en, this message translates to: + /// **'replied to your reply'** + String get notificationRepliedReply; + + /// Fallback notification action text + /// + /// In en, this message translates to: + /// **'notified you'** + String get notificationNotified; + /// Message when all notifications are read /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index ee0a188e..0828f414 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -701,6 +701,105 @@ class AppLocalizationsEn extends AppLocalizations { @override String get emptyNoNotifications => 'No notifications'; + @override + String notificationLikedPost(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others liked your post', + one: 'and 1 other liked your post', + zero: 'liked your post', + ); + return '$_temp0'; + } + + @override + String notificationLikedReply(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others liked your reply', + one: 'and 1 other liked your reply', + zero: 'liked your reply', + ); + return '$_temp0'; + } + + @override + String notificationLikedRepost(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others liked your repost', + one: 'and 1 other liked your repost', + zero: 'liked your repost', + ); + return '$_temp0'; + } + + @override + String notificationRepostedPost(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others reposted your post', + one: 'and 1 other reposted your post', + zero: 'reposted your post', + ); + return '$_temp0'; + } + + @override + String notificationRepostedReply(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others reposted your reply', + one: 'and 1 other reposted your reply', + zero: 'reposted your reply', + ); + return '$_temp0'; + } + + @override + String notificationRepostedRepost(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others reposted your repost', + one: 'and 1 other reposted your repost', + zero: 'reposted your repost', + ); + return '$_temp0'; + } + + @override + String notificationFollowed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'and $count others followed you', + one: 'and 1 other followed you', + zero: 'followed you', + ); + return '$_temp0'; + } + + @override + String get notificationFollowedBack => 'followed you back'; + + @override + String get notificationMentioned => 'mentioned you'; + + @override + String get notificationRepliedPost => 'replied to your post'; + + @override + String get notificationRepliedReply => 'replied to your reply'; + + @override + String get notificationNotified => 'notified you'; + @override String get messageAllCaughtUp => 'You\'re all caught up!'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 7827ed32..67a079cf 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -1145,6 +1145,101 @@ "description": "Empty state for no notifications" }, + "notificationLikedPost": "{count, plural, =0{liked your post} =1{and 1 other liked your post} other{and {count} others liked your post}}", + "@notificationLikedPost": { + "description": "Notification action text for likes on the viewer's post", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationLikedReply": "{count, plural, =0{liked your reply} =1{and 1 other liked your reply} other{and {count} others liked your reply}}", + "@notificationLikedReply": { + "description": "Notification action text for likes on the viewer's reply", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationLikedRepost": "{count, plural, =0{liked your repost} =1{and 1 other liked your repost} other{and {count} others liked your repost}}", + "@notificationLikedRepost": { + "description": "Notification action text when someone liked content through the viewer's repost", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationRepostedPost": "{count, plural, =0{reposted your post} =1{and 1 other reposted your post} other{and {count} others reposted your post}}", + "@notificationRepostedPost": { + "description": "Notification action text for reposts of the viewer's post", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationRepostedReply": "{count, plural, =0{reposted your reply} =1{and 1 other reposted your reply} other{and {count} others reposted your reply}}", + "@notificationRepostedReply": { + "description": "Notification action text for reposts of the viewer's reply", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationRepostedRepost": "{count, plural, =0{reposted your repost} =1{and 1 other reposted your repost} other{and {count} others reposted your repost}}", + "@notificationRepostedRepost": { + "description": "Notification action text when someone reposted content through the viewer's repost", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationFollowed": "{count, plural, =0{followed you} =1{and 1 other followed you} other{and {count} others followed you}}", + "@notificationFollowed": { + "description": "Notification action text for follows", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "notificationFollowedBack": "followed you back", + "@notificationFollowedBack": { + "description": "Notification action text for a follow-back" + }, + + "notificationMentioned": "mentioned you", + "@notificationMentioned": { + "description": "Notification action text for mentions" + }, + + "notificationRepliedPost": "replied to your post", + "@notificationRepliedPost": { + "description": "Notification action text for replies to the viewer's post" + }, + + "notificationRepliedReply": "replied to your reply", + "@notificationRepliedReply": { + "description": "Notification action text for replies to the viewer's reply" + }, + + "notificationNotified": "notified you", + "@notificationNotified": { + "description": "Fallback notification action text" + }, + "messageAllCaughtUp": "You''re all caught up!", "@messageAllCaughtUp": { "description": "Message when all notifications are read" diff --git a/lib/src/core/notifications/notification_navigation.dart b/lib/src/core/notifications/notification_navigation.dart index c02c70c9..7f171154 100644 --- a/lib/src/core/notifications/notification_navigation.dart +++ b/lib/src/core/notifications/notification_navigation.dart @@ -1,3 +1,5 @@ +import 'package:poptart/poptart.dart'; + typedef ReplyNotificationTarget = ({ String postUri, String highlightedReplyUri, @@ -9,11 +11,76 @@ String? notificationPayloadString(Object? value) { /// Returns the URI for the record that caused a push notification. /// -/// AppView notification payloads use `uri`, while older mobile payload handling -/// expected `recordUri`. +/// Native push payloads follow Bluesky's shape and use `uri`. The server may +/// temporarily emit legacy `recordUri`, but new clients should not read it. String? notificationRecordUri(Map data) { - return notificationPayloadString(data['uri']) ?? - notificationPayloadString(data['recordUri']); + return notificationPayloadString(data['uri']); +} + +String? notificationRecordAuthorDid(String? uri) { + if (uri == null) return null; + try { + return AtUri.parse(uri).hostname.toString(); + } catch (_) { + return null; + } +} + +bool notificationIsRouteablePostUri(String uri) { + try { + final collection = AtUri.parse(uri).collection.toString(); + return collection.startsWith('so.sprk.feed.post') || + collection == 'so.sprk.feed.reply' || + collection.startsWith('app.bsky.feed.post'); + } catch (_) { + return false; + } +} + +String? notificationEmbeddedSubjectPostUri(Map? record) { + final subject = record?['subject']; + if (subject is! Map) { + return null; + } + + final uri = notificationPayloadString(subject['uri']); + return uri != null && notificationIsRouteablePostUri(uri) ? uri : null; +} + +bool notificationReasonIsViaRepost(String? reason) { + return reason == 'like-via-repost' || reason == 'repost-via-repost'; +} + +String? notificationPostRouteUri({ + required String? reason, + String? reasonSubject, + String? recordUri, + Map? record, + Map? payload, +}) { + final subject = notificationPayloadString(payload?['subject']); + if (subject != null && notificationIsRouteablePostUri(subject)) { + return subject; + } + + final embeddedSubjectUri = notificationEmbeddedSubjectPostUri(record); + if (embeddedSubjectUri != null) { + return embeddedSubjectUri; + } + + if (notificationReasonIsViaRepost(reason)) { + return null; + } + + if (reasonSubject != null && notificationIsRouteablePostUri(reasonSubject)) { + return reasonSubject; + } + + if (recordUri != null && notificationIsRouteablePostUri(recordUri)) { + return recordUri; + } + + return null; } ReplyNotificationTarget replyNotificationTarget({ @@ -34,6 +101,6 @@ ReplyNotificationTarget? replyNotificationTargetFromPayload( return replyNotificationTarget( replyUri: replyUri, - reasonSubject: notificationPayloadString(data['reasonSubject']), + reasonSubject: notificationPayloadString(data['subject']), ); } diff --git a/lib/src/core/notifications/push_notification_service.dart b/lib/src/core/notifications/push_notification_service.dart index c3ff11f0..617a9cb0 100644 --- a/lib/src/core/notifications/push_notification_service.dart +++ b/lib/src/core/notifications/push_notification_service.dart @@ -90,12 +90,16 @@ class PushNotificationService { void _handleNotificationTap(RemoteMessage message) { final data = message.data; final reason = notificationPayloadString(data['reason']); - final author = notificationPayloadString(data['author']); final recordUri = notificationRecordUri(data); - final reasonSubject = notificationPayloadString(data['reasonSubject']); + final authorDid = notificationRecordAuthorDid(recordUri); final replyTarget = reason == 'reply' ? replyNotificationTargetFromPayload(data) : null; + final postRouteUri = notificationPostRouteUri( + reason: reason, + recordUri: recordUri, + payload: data, + ); if (!GetIt.instance.isRegistered()) { _pendingNotification = message; @@ -104,9 +108,9 @@ class PushNotificationService { final router = GetIt.instance(); - if (reason == 'follow' && author != null) { + if (reason == 'follow' && authorDid != null) { // Navigate to profile for follow notifications - router.push(ProfileRoute(did: author)); + router.push(ProfileRoute(did: authorDid)); } else if (replyTarget != null) { router.push( StandalonePostRoute( @@ -114,15 +118,8 @@ class PushNotificationService { highlightedReplyUri: replyTarget.highlightedReplyUri, ), ); - } else if (reasonSubject != null) { - // For likes/reposts, navigate to the subject (the post being liked/reposted) - router.push(StandalonePostRoute(postUri: reasonSubject)); - } else if (recordUri != null) { - // For mentions and other record notifications, navigate to the record itself - router.push(StandalonePostRoute(postUri: recordUri)); - } else if (author != null) { - // Fallback to author profile - router.push(ProfileRoute(did: author)); + } else if (postRouteUri != null) { + router.push(StandalonePostRoute(postUri: postRouteUri)); } } diff --git a/lib/src/features/notifications/models/grouped_notification.dart b/lib/src/features/notifications/models/grouped_notification.dart index 12379c2b..ad5db494 100644 --- a/lib/src/features/notifications/models/grouped_notification.dart +++ b/lib/src/features/notifications/models/grouped_notification.dart @@ -68,8 +68,7 @@ List groupNotifications(List notifications) { final result = []; final followGroups = >{}; - final likeGroups = >{}; - final repostGroups = >{}; + final reactionGroups = >{}; // First pass: collect all groupable notifications for (final notification in notifications) { @@ -85,19 +84,14 @@ List groupNotifications(List notifications) { followGroups.putIfAbsent('follows', () => []).add(notification); } case 'like': - // Group likes by reasonSubject (the post/reply being liked) - if (notification.reasonSubject != null) { - final key = notification.reasonSubject.toString(); - likeGroups.putIfAbsent(key, () => []).add(notification); - } else { - // No subject, don't group - result.add(GroupedNotification.single(notification)); - } + case 'like-via-repost': case 'repost': - // Group reposts by reasonSubject + case 'repost-via-repost': + // Group reactions by exact reason and subject. if (notification.reasonSubject != null) { - final key = notification.reasonSubject.toString(); - repostGroups.putIfAbsent(key, () => []).add(notification); + final key = + '${notification.reasonValue}:${notification.reasonSubject}'; + reactionGroups.putIfAbsent(key, () => []).add(notification); } else { result.add(GroupedNotification.single(notification)); } @@ -123,26 +117,14 @@ List groupNotifications(List notifications) { ); } - // Add like groups - for (final entry in likeGroups.entries) { - final likes = entry.value - ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); - allGroups.add( - GroupedNotification( - primaryNotification: likes.first, - notifications: likes, - ), - ); - } - - // Add repost groups - for (final entry in repostGroups.entries) { - final reposts = entry.value + // Add reaction groups + for (final entry in reactionGroups.entries) { + final reactions = entry.value ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); allGroups.add( GroupedNotification( - primaryNotification: reposts.first, - notifications: reposts, + primaryNotification: reactions.first, + notifications: reactions, ), ); } diff --git a/lib/src/features/notifications/ui/widgets/notification_item.dart b/lib/src/features/notifications/ui/widgets/notification_item.dart index 7c289add..d745a399 100644 --- a/lib/src/features/notifications/ui/widgets/notification_item.dart +++ b/lib/src/features/notifications/ui/widgets/notification_item.dart @@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/notification_models.dart' as models; import 'package:spark/src/core/notifications/notification_navigation.dart'; @@ -93,7 +94,7 @@ class _NotificationItemState extends ConsumerState { } Widget _getReasonIcon(String reason, Color color) { - switch (reason) { + switch (_baseReason(reason)) { case 'like': return AppIcons.likeFilled(color: color); case 'repost': @@ -109,7 +110,7 @@ class _NotificationItemState extends ConsumerState { } Color _getReasonColor(String reason) { - switch (reason) { + switch (_baseReason(reason)) { case 'like': return AppColors.likeColor; case 'repost': @@ -124,49 +125,47 @@ class _NotificationItemState extends ConsumerState { } } - String _getReasonText(String reason, int othersCount) { - final hasOthers = othersCount > 0; - final othersText = hasOthers ? ' and $othersCount others' : ''; - + String _getReasonText(AppLocalizations l10n, String reason, int othersCount) { switch (reason) { case 'like': - // Check if reasonSubject is a reply or post if (notification.reasonSubject != null) { final collection = notification.reasonSubject!.collection.toString(); if (collection.contains('reply')) { - return '$othersText liked your reply'; + return l10n.notificationLikedReply(othersCount); } } - return '$othersText liked your post'; + return l10n.notificationLikedPost(othersCount); + case 'like-via-repost': + return l10n.notificationLikedRepost(othersCount); case 'repost': - // Check if reasonSubject is a reply or post if (notification.reasonSubject != null) { final collection = notification.reasonSubject!.collection.toString(); if (collection.contains('reply')) { - return '$othersText reposted your reply'; + return l10n.notificationRepostedReply(othersCount); } } - return '$othersText reposted your post'; + return l10n.notificationRepostedPost(othersCount); + case 'repost-via-repost': + return l10n.notificationRepostedRepost(othersCount); case 'follow': // Check if this is a follow-back (viewer follows the author) final isFollowBack = notification.author.viewer?.following != null; if (isFollowBack) { - return 'followed you back'; + return l10n.notificationFollowedBack; } - return '$othersText followed you'; + return l10n.notificationFollowed(othersCount); case 'mention': - return 'mentioned you'; + return l10n.notificationMentioned; case 'reply': - // Check if reasonSubject is a reply or post if (notification.reasonSubject != null) { final collection = notification.reasonSubject!.collection.toString(); if (collection.contains('reply')) { - return 'replied to your reply'; + return l10n.notificationRepliedReply; } } - return 'replied to your post'; + return l10n.notificationRepliedPost; default: - return 'notified you'; + return l10n.notificationNotified; } } @@ -187,17 +186,15 @@ class _NotificationItemState extends ConsumerState { highlightedReplyUri: target.highlightedReplyUri, ), ); - } else if (notification.reasonSubject != null) { - // Navigate to the post/thread - final reasonSubjectStr = notification.reasonSubject!.toString(); - context.router.push(StandalonePostRoute(postUri: reasonSubjectStr)); } else { - final collectionStr = notification.uri.collection.toString(); - if (collectionStr.startsWith('so.sprk.feed.post') || - collectionStr.startsWith('app.bsky.feed.post')) { - // Navigate to the post - final uriStr = notification.uri.toString(); - context.router.push(StandalonePostRoute(postUri: uriStr)); + final postUri = notificationPostRouteUri( + reason: reason, + reasonSubject: notification.reasonSubject?.toString(), + recordUri: notification.uri.toString(), + record: notification.record, + ); + if (postUri != null) { + context.router.push(StandalonePostRoute(postUri: postUri)); return; } // Fallback to author profile @@ -212,7 +209,7 @@ class _NotificationItemState extends ConsumerState { // For like/repost notifications, get text from the subject record final reason = notification.reasonValue; - if (reason == 'like' || reason == 'repost') { + if (_isSubjectReaction(reason)) { final subject = notification.record['subject'] as Map?; if (subject != null) { recordToCheck = subject; @@ -246,7 +243,7 @@ class _NotificationItemState extends ConsumerState { // For like/repost notifications, check for subjectMedia at top level first final reason = notification.reasonValue; - if (reason == 'like' || reason == 'repost') { + if (_isSubjectReaction(reason)) { // Backend embeds subjectMedia at top level for like/repost notifications media = record['subjectMedia'] as Map?; // Fallback: check if subject has media directly @@ -355,6 +352,21 @@ class _NotificationItemState extends ConsumerState { return null; } + String _baseReason(String reason) { + return switch (reason) { + 'like-via-repost' => 'like', + 'repost-via-repost' => 'repost', + _ => reason, + }; + } + + bool _isSubjectReaction(String reason) { + return reason == 'like' || + reason == 'like-via-repost' || + reason == 'repost' || + reason == 'repost-via-repost'; + } + String _formatTimeAgoShort(Duration difference) { if (difference.inDays > 0) { return '${difference.inDays}d'; @@ -463,7 +475,11 @@ class _NotificationItemState extends ConsumerState { final othersCount = widget.groupedNotification.othersCount; final reasonColor = _getReasonColor(reason); final reasonIcon = _getReasonIcon(reason, reasonColor); - final reasonText = _getReasonText(reason, othersCount); + final reasonText = _getReasonText( + AppLocalizations.of(context), + reason, + othersCount, + ); final contentPreview = _getContentPreview(); final mediaUrl = _getMediaUrl(); final now = DateTime.now(); diff --git a/test/src/core/notifications/push_notification_service_test.dart b/test/src/core/notifications/push_notification_service_test.dart index 850becb8..67ffbe59 100644 --- a/test/src/core/notifications/push_notification_service_test.dart +++ b/test/src/core/notifications/push_notification_service_test.dart @@ -3,7 +3,7 @@ import 'package:spark/src/core/notifications/notification_navigation.dart'; void main() { group('notificationRecordUri', () { - test('prefers canonical uri over legacy recordUri', () { + test('uses canonical uri', () { expect( notificationRecordUri({ 'uri': 'at://did:plc:reply/so.sprk.feed.reply/123', @@ -13,12 +13,12 @@ void main() { ); }); - test('falls back to legacy recordUri', () { + test('does not read legacy recordUri', () { expect( notificationRecordUri({ 'recordUri': 'at://did:plc:reply/so.sprk.feed.reply/123', }), - 'at://did:plc:reply/so.sprk.feed.reply/123', + isNull, ); }); @@ -27,6 +27,17 @@ void main() { }); }); + group('notificationRecordAuthorDid', () { + test('extracts the author did from a push uri', () { + expect( + notificationRecordAuthorDid( + 'at://did:plc:author/app.bsky.graph.follow/123', + ), + 'did:plc:author', + ); + }); + }); + group('replyNotificationTarget', () { test('uses reasonSubject as the displayed post or parent thread', () { final target = replyNotificationTarget( @@ -58,7 +69,7 @@ void main() { test('builds a target from push payload fields', () { final target = replyNotificationTargetFromPayload({ 'uri': 'at://did:plc:reply/so.sprk.feed.reply/123', - 'reasonSubject': 'at://did:plc:root/so.sprk.feed.post/456', + 'subject': 'at://did:plc:root/so.sprk.feed.post/456', }); expect(target?.postUri, 'at://did:plc:root/so.sprk.feed.post/456'); @@ -68,4 +79,61 @@ void main() { ); }); }); + + group('notificationPostRouteUri', () { + test('uses embedded subject post for via-repost notifications', () { + expect( + notificationPostRouteUri( + reason: 'like-via-repost', + reasonSubject: 'at://did:plc:user/so.sprk.feed.repost/123', + record: { + 'subject': {'uri': 'at://did:plc:author/so.sprk.feed.post/456'}, + }, + ), + 'at://did:plc:author/so.sprk.feed.post/456', + ); + }); + + test('does not route via-repost notifications to the repost record', () { + expect( + notificationPostRouteUri( + reason: 'repost-via-repost', + reasonSubject: 'at://did:plc:user/so.sprk.feed.repost/123', + recordUri: 'at://did:plc:actor/app.bsky.feed.like/456', + ), + isNull, + ); + }); + + test('uses push subject for via-repost payloads', () { + expect( + notificationPostRouteUri( + reason: 'like-via-repost', + reasonSubject: 'at://did:plc:user/so.sprk.feed.repost/123', + payload: {'subject': 'at://did:plc:author/so.sprk.feed.post/456'}, + ), + 'at://did:plc:author/so.sprk.feed.post/456', + ); + }); + + test('keeps routing normal reactions by reasonSubject', () { + expect( + notificationPostRouteUri( + reason: 'like', + reasonSubject: 'at://did:plc:author/so.sprk.feed.post/456', + ), + 'at://did:plc:author/so.sprk.feed.post/456', + ); + }); + + test('falls back to routeable record uri for mentions', () { + expect( + notificationPostRouteUri( + reason: 'mention', + recordUri: 'at://did:plc:author/so.sprk.feed.post/456', + ), + 'at://did:plc:author/so.sprk.feed.post/456', + ); + }); + }); } diff --git a/test/src/features/notifications/models/grouped_notification_test.dart b/test/src/features/notifications/models/grouped_notification_test.dart index 09e780f3..89753fe1 100644 --- a/test/src/features/notifications/models/grouped_notification_test.dart +++ b/test/src/features/notifications/models/grouped_notification_test.dart @@ -142,6 +142,68 @@ void main() { expect(results.first.actorCount, 2); }); + test('groups likes via repost by reasonSubject', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.repost/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'like-via-repost', + reasonSubject: subject, + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'like-via-repost', + reasonSubject: subject, + ), + ]); + + expect(results, hasLength(1)); + expect(results.first.reason, 'like-via-repost'); + expect(results.first.actorCount, 2); + }); + + test('groups reposts via repost by reasonSubject', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.repost/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'repost-via-repost', + reasonSubject: subject, + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'repost-via-repost', + reasonSubject: subject, + ), + ]); + + expect(results, hasLength(1)); + expect(results.first.reason, 'repost-via-repost'); + expect(results.first.actorCount, 2); + }); + + test('does not merge different reaction reasons for the same subject', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.repost/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'like-via-repost', + reasonSubject: subject, + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'repost-via-repost', + reasonSubject: subject, + ), + ]); + + expect(results, hasLength(2)); + expect( + results.map((result) => result.reason), + containsAll(['like-via-repost', 'repost-via-repost']), + ); + }); + test('does not group replies', () { final results = groupNotifications([ _makeNotification(did: 'did:plc:alice', reason: 'reply'), -- 2.51.2