Private messages (NIP-17) arrived in the foreground with no signal: no unread count, no badge, no OS notification. Hook both into the single InboxService.ingest choke point. - UnreadService: per-peer "last read" in the keystore (timestamps+pubkey only, no message text), live count via a changes stream, active-peer suppression so the open chat never badges or notifies. - UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger, the drawer "Chat" item, and per-conversation in the messages list. - Mark-read on chat open (ChatScreen). - NotificationService over flutter_local_notifications: generic "New message from <name>" (no message text, for privacy), payload = pubkey, tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS. - i18n notifications.newMessageFrom (en/es/pt/ast). Background/push stays out of scope (foreground-only by design). Tests: UnreadService, NotificationService (mock plugin), InboxService hooks (new peer notifies/counts; duplicate, own, and open-chat do not), and a UnreadBadge widget test. dart analyze clean; commons_core green.
78 lines
2.8 KiB
Dart
78 lines
2.8 KiB
Dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:tane/services/notification_service.dart';
|
|
|
|
class _MockPlugin extends Mock implements FlutterLocalNotificationsPlugin {}
|
|
|
|
void main() {
|
|
late _MockPlugin plugin;
|
|
|
|
setUpAll(() {
|
|
registerFallbackValue(
|
|
const NotificationDetails(
|
|
android: AndroidNotificationDetails('x', 'x'),
|
|
),
|
|
);
|
|
registerFallbackValue(const InitializationSettings());
|
|
});
|
|
|
|
setUp(() {
|
|
plugin = _MockPlugin();
|
|
when(() => plugin.show(any(), any(), any(), any(),
|
|
payload: any(named: 'payload'))).thenAnswer((_) async {});
|
|
// Generic platform resolution returns null, so the permission requests in
|
|
// initialize() are skipped in tests.
|
|
when(() => plugin.resolvePlatformSpecificImplementation())
|
|
.thenReturn(null);
|
|
});
|
|
|
|
test('showMessage passes the title and pubkey payload, no body', () async {
|
|
final service = NotificationService(plugin: plugin, supported: true);
|
|
await service.showMessage(
|
|
peerPubkey: 'abc123', title: 'New message from Alice');
|
|
final captured = verify(() => plugin.show(
|
|
captureAny(),
|
|
captureAny(),
|
|
captureAny(),
|
|
captureAny(),
|
|
payload: captureAny(named: 'payload'),
|
|
)).captured;
|
|
expect(captured[1], 'New message from Alice'); // title
|
|
expect(captured[2], isNull); // no body (privacy: no message text)
|
|
expect(captured[4], 'abc123'); // payload = peer pubkey
|
|
});
|
|
|
|
test('is a no-op on unsupported platforms (web/windows)', () async {
|
|
final service = NotificationService(plugin: plugin, supported: false);
|
|
await service.initialize();
|
|
await service.showMessage(peerPubkey: 'abc123', title: 'hi');
|
|
verifyNever(() => plugin.show(any(), any(), any(), any(),
|
|
payload: any(named: 'payload')));
|
|
});
|
|
|
|
test('a tap with a payload invokes onTapChat with the pubkey', () async {
|
|
// Capture the response handler initialize() registers, then simulate a tap.
|
|
void Function(NotificationResponse)? handler;
|
|
when(() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse:
|
|
any(named: 'onDidReceiveNotificationResponse'),
|
|
)).thenAnswer((inv) async {
|
|
handler = inv.namedArguments[#onDidReceiveNotificationResponse]
|
|
as void Function(NotificationResponse)?;
|
|
return true;
|
|
});
|
|
|
|
final service = NotificationService(plugin: plugin, supported: true);
|
|
String? tapped;
|
|
service.onTapChat = (pk) => tapped = pk;
|
|
await service.initialize();
|
|
|
|
handler!(const NotificationResponse(
|
|
notificationResponseType: NotificationResponseType.selectedNotification,
|
|
payload: 'abc123',
|
|
));
|
|
expect(tapped, 'abc123');
|
|
});
|
|
}
|