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:
commit
48db8fa7c8
27 changed files with 1238 additions and 114 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
}
|
||||
90
apps/app_seeds/test/services/unread_service_test.dart
Normal file
90
apps/app_seeds/test/services/unread_service_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue