Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications

# Conflicts:
#	apps/app_seeds/lib/di/injector.dart
#	apps/app_seeds/lib/i18n/ast.i18n.json
#	apps/app_seeds/lib/i18n/en.i18n.json
#	apps/app_seeds/lib/i18n/es.i18n.json
#	apps/app_seeds/lib/i18n/pt.i18n.json
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/i18n/strings_ast.g.dart
#	apps/app_seeds/lib/i18n/strings_en.g.dart
#	apps/app_seeds/lib/i18n/strings_es.g.dart
#	apps/app_seeds/lib/i18n/strings_pt.g.dart
#	apps/app_seeds/lib/ui/chat_screen.dart
This commit is contained in:
vjrj 2026-07-10 21:15:59 +02:00
commit 48db8fa7c8
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();
});
}

View file

@ -0,0 +1,74 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/unread_service.dart';
import 'package:tane/ui/unread_badge.dart';
import '../support/test_support.dart';
/// The badge is driven by [UnreadService]: it shows a count when there are
/// unread messages and clears when they're read — live, over the service's
/// `changes` stream. Injecting the service avoids the `getIt` lookup. The store
/// reads are real async, so mutations run inside `runAsync`.
void main() {
late MessageStore store;
late UnreadService unread;
setUp(() {
store = MessageStore(InMemorySecretStore());
unread = UnreadService(store, InMemorySecretStore());
});
tearDown(() => unread.dispose());
Widget host() => MaterialApp(
home: Scaffold(
body: UnreadBadge(
peer: 'alice',
service: unread,
child: const Icon(Icons.chat_bubble),
),
),
);
Future<void> receive(int atMs) async {
final m = PrivateMessage(
fromPubkey: 'alice',
text: 'hi',
at: DateTime.fromMillisecondsSinceEpoch(atMs));
await store.append('alice', m);
await unread.onMessageReceived('alice', m);
}
// Lets pending async (store reads + the widget's stream-driven refresh)
// resolve in the real zone, then renders a frame.
Future<void> pumpRefresh(WidgetTester tester) async {
for (var i = 0; i < 5; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
}
}
testWidgets('shows an existing unread count on build', (tester) async {
await tester.runAsync(() => receive(1000));
await tester.pumpWidget(host());
await pumpRefresh(tester);
expect(find.text('1'), findsOneWidget);
});
testWidgets('reacts to a new message and to being read', (tester) async {
await tester.pumpWidget(host());
await pumpRefresh(tester);
expect(find.text('1'), findsNothing);
await tester.runAsync(() => receive(1000));
await pumpRefresh(tester);
expect(find.text('1'), findsOneWidget);
await tester.runAsync(() => unread.markRead('alice'));
await pumpRefresh(tester);
expect(find.text('1'), findsNothing);
});
}