feat(messages): unread badges + OS notification for private messages
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.
This commit is contained in:
parent
4d110e8082
commit
d026376993
27 changed files with 1238 additions and 114 deletions
78
apps/app_seeds/test/services/notification_service_test.dart
Normal file
78
apps/app_seeds/test/services/notification_service_test.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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');
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue