- Anchor the message list to the bottom (`reverse: true`) so new messages stay in view instead of landing below the fold. - Move chat history from the OS keystore (O(n²) JSON blob, silent 200-msg cap) to a separate encrypted Drift/SQLCipher DB (`ChatDatabase`): indexed append, uncapped history, dedup as a unique-key invariant. It's an ephemeral per-device cache, isolated from the inventory schema, its migrations, and its sync. No data migration (pre-release). - Add day separators (Today/Yesterday/locale date) and a per-bubble time, all via ICU (12/24h per locale; Localizations locale maps Asturian → Spanish for intl date symbols). - Add peer avatars (deterministic colour from the pubkey + name initial), surface send failures that were previously silent, and make bubble text selectable (addresses, links). - New i18n keys in en/es/pt/ast; tests for grouping, formatting, avatars, scroll anchoring, storage and send errors. Docs: docs/design/chat-storage.md + open-decisions.md.
129 lines
4.4 KiB
Dart
129 lines
4.4 KiB
Dart
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_connection.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, updates unread and
|
|
/// notifies — even when the specific chat isn't open. Driven through the
|
|
/// [InboxService.ingest] seam so no relay/network is involved.
|
|
void main() {
|
|
const seedHex =
|
|
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
|
|
|
// A connection that never actually opens — ingest doesn't use it.
|
|
Future<SocialConnection> offlineConnection() async => SocialConnection(
|
|
social: await SocialService.fromRootSeedHex(seedHex),
|
|
settings: SocialSettings(InMemorySecretStore()),
|
|
open: (_) async => throw StateError('unused in ingest tests'),
|
|
online: const Stream.empty(),
|
|
);
|
|
|
|
late MessageStore store;
|
|
late InboxService inbox;
|
|
|
|
setUp(() async {
|
|
store = MessageStore(newTestChatDatabase());
|
|
inbox = InboxService(
|
|
connection: await offlineConnection(),
|
|
selfPubkey: 'me',
|
|
store: store,
|
|
);
|
|
});
|
|
|
|
tearDown(() => inbox.stop());
|
|
|
|
PrivateMessage msg(String from, String text, int atMs) => PrivateMessage(
|
|
fromPubkey: from,
|
|
text: text,
|
|
at: DateTime.fromMillisecondsSinceEpoch(atMs));
|
|
|
|
test('an incoming message is persisted into its conversation', () async {
|
|
await inbox.ingest(msg('alice', 'got seeds?', 1000));
|
|
final convos = await store.conversations();
|
|
expect(convos.single.peerPubkey, 'alice');
|
|
expect(convos.single.lastText, 'got seeds?');
|
|
});
|
|
|
|
test('a new message announces a change; a duplicate stays silent', () async {
|
|
final changes = <void>[];
|
|
final sub = inbox.changes.listen(changes.add);
|
|
|
|
await inbox.ingest(msg('alice', 'hola', 1000));
|
|
await inbox.ingest(msg('alice', 'hola', 1000)); // relay re-delivery
|
|
await Future<void>.delayed(Duration.zero); // let the broadcast flush
|
|
|
|
expect(changes, hasLength(1)); // only the first, new one fired
|
|
expect(await store.history('alice'), hasLength(1));
|
|
await sub.cancel();
|
|
});
|
|
|
|
group('unread + notification hooks', () {
|
|
late UnreadService unread;
|
|
late _RecordingNotifications notifications;
|
|
const myPubkey = 'me';
|
|
|
|
setUp(() async {
|
|
unread = UnreadService(store, InMemorySecretStore());
|
|
notifications = _RecordingNotifications();
|
|
inbox = InboxService(
|
|
connection: await offlineConnection(),
|
|
selfPubkey: myPubkey,
|
|
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);
|
|
});
|
|
});
|
|
}
|