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 4d110e8082
commit d026376993
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);
});
});
}