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:
vjrj 2026-07-10 21:12:00 +02:00
parent 63f48db5c2
commit 5fe0f4540e
27 changed files with 1238 additions and 114 deletions

View file

@ -2,11 +2,30 @@ import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/inbox_service.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/services/unread_service.dart';
import '../support/test_support.dart';
/// Records notification calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final titles = <String>[];
final peers = <String>[];
@override
Future<void> showMessage({
required String peerPubkey,
required String title,
}) async {
peers.add(peerPubkey);
titles.add(title);
}
}
/// The app-wide inbox listener persists incoming messages and announces changes,
/// so the inbox list refreshes even when the specific chat isn't open. Driven
/// through the [InboxService.ingest] seam so no relay/network is involved.
@ -52,4 +71,52 @@ void main() {
expect(await store.history('alice'), hasLength(1));
await sub.cancel();
});
group('unread + notification hooks', () {
late UnreadService unread;
late _RecordingNotifications notifications;
late String myPubkey;
setUp(() async {
final social = await SocialService.fromRootSeedHex(seedHex);
myPubkey = social.publicKeyHex;
unread = UnreadService(store, InMemorySecretStore());
notifications = _RecordingNotifications();
inbox = InboxService(
social: social,
settings: SocialSettings(InMemorySecretStore()),
store: store,
unread: unread,
notifications: notifications,
);
});
tearDown(() => unread.dispose());
test('a new peer message updates unread and notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, ['alice']);
});
test('a duplicate re-delivery neither counts nor notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, hasLength(1));
});
test('a message authored by me is never notified', () async {
// NIP-17 doesn't loop your own gift wrap back; the guard is defensive.
await inbox.ingest(msg(myPubkey, 'echo', 1000));
expect(notifications.peers, isEmpty);
});
test('the open chat is not notified and stays read', () async {
unread.activePeer = 'alice';
await inbox.ingest(msg('alice', 'while open', 1000));
expect(await unread.unreadCount('alice'), 0);
expect(notifications.peers, isEmpty);
});
});
}

View 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');
});
}

View file

@ -0,0 +1,90 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/unread_service.dart';
import '../support/test_support.dart';
/// Unread counting is derived from stored history plus a per-peer "last read"
/// mark; these tests drive it through a real [MessageStore] over an in-memory
/// keystore, so no relay or network is involved.
void main() {
const me = 'me-pubkey';
late MessageStore store;
late UnreadService unread;
setUp(() {
store = MessageStore(InMemorySecretStore());
unread = UnreadService(store, InMemorySecretStore());
});
tearDown(() => unread.dispose());
PrivateMessage msg(String from, String text, int atMs) => PrivateMessage(
fromPubkey: from,
text: text,
at: DateTime.fromMillisecondsSinceEpoch(atMs));
// Persist then notify, the way InboxService.ingest does.
Future<void> receive(String peer, PrivateMessage m) async {
await store.append(peer, m);
await unread.onMessageReceived(peer, m);
}
test('an unknown peer has no unread', () async {
expect(await unread.unreadCount('alice'), 0);
});
test('inbound messages increment the unread count', () async {
await receive('alice', msg('alice', 'hi', 1000));
await receive('alice', msg('alice', 'you there?', 2000));
expect(await unread.unreadCount('alice'), 2);
});
test('markRead clears older messages but keeps newer ones', () async {
await receive('alice', msg('alice', 'a', 1000));
await unread.markRead('alice'); // reads up to the latest (1000)
expect(await unread.unreadCount('alice'), 0);
await receive('alice', msg('alice', 'b', 2000));
expect(await unread.unreadCount('alice'), 1);
});
test('own outgoing messages never count as unread', () async {
await receive('alice', msg(me, 'my reply', 1000));
expect(await unread.unreadCount('alice'), 0);
});
test('a message for the open chat is auto-read (no unread)', () async {
unread.activePeer = 'alice';
await receive('alice', msg('alice', 'while open', 1000));
expect(await unread.unreadCount('alice'), 0);
});
test('totalUnreadCount sums across conversations', () async {
await receive('alice', msg('alice', 'a', 1000));
await receive('bob', msg('bob', 'b1', 1000));
await receive('bob', msg('bob', 'b2', 2000));
expect(await unread.totalUnreadCount(), 3);
});
test('changes fires on a new inbound message', () async {
final events = <void>[];
final sub = unread.changes.listen(events.add);
await receive('alice', msg('alice', 'hi', 1000));
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
await sub.cancel();
});
test('changes fires on markRead', () async {
await receive('alice', msg('alice', 'hi', 1000));
final events = <void>[];
final sub = unread.changes.listen(events.add);
await unread.markRead('alice');
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
await sub.cancel();
});
}