feat(chat): usable 1:1 chat — bottom-anchored, Drift-backed, dated
- 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.
This commit is contained in:
parent
44337497d0
commit
68b04ea409
27 changed files with 1793 additions and 264 deletions
120
apps/app_seeds/test/domain/chat_timeline_test.dart
Normal file
120
apps/app_seeds/test/domain/chat_timeline_test.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:tane/domain/chat_timeline.dart';
|
||||
|
||||
void main() {
|
||||
// In the app, flutter_localizations loads intl's locale symbols; a pure test
|
||||
// must initialize them itself before any DateFormat.
|
||||
setUpAll(initializeDateFormatting);
|
||||
|
||||
PrivateMessage at(DateTime when) =>
|
||||
PrivateMessage(fromPubkey: 'p', text: 't', at: when);
|
||||
|
||||
group('chatTimeline', () {
|
||||
test('empty in, empty out', () {
|
||||
expect(chatTimeline(const []), isEmpty);
|
||||
});
|
||||
|
||||
test('a day separator precedes the first message of each day', () {
|
||||
final items = chatTimeline([
|
||||
at(DateTime(2026, 6, 14, 10)),
|
||||
at(DateTime(2026, 6, 14, 11)),
|
||||
at(DateTime(2026, 6, 15, 9)),
|
||||
]);
|
||||
// sep, msg, msg, sep, msg
|
||||
expect(items.map((i) => i is ChatDaySeparator), [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
expect((items[0] as ChatDaySeparator).day, DateTime(2026, 6, 14));
|
||||
expect((items[3] as ChatDaySeparator).day, DateTime(2026, 6, 15));
|
||||
});
|
||||
|
||||
test('messages stay oldest-first', () {
|
||||
final items = chatTimeline([
|
||||
at(DateTime(2026, 1, 1, 8)),
|
||||
at(DateTime(2026, 1, 1, 9)),
|
||||
]);
|
||||
expect(items.whereType<ChatMessageRow>(), hasLength(2));
|
||||
expect(items.whereType<ChatDaySeparator>(), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('chatDayLabel', () {
|
||||
final now = DateTime(2026, 6, 15, 12);
|
||||
|
||||
test('the two most recent days use the given today/yesterday labels', () {
|
||||
expect(
|
||||
chatDayLabel(
|
||||
DateTime(2026, 6, 15),
|
||||
now: now,
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
localeCode: 'en',
|
||||
),
|
||||
'Today',
|
||||
);
|
||||
expect(
|
||||
chatDayLabel(
|
||||
DateTime(2026, 6, 14),
|
||||
now: now,
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
localeCode: 'en',
|
||||
),
|
||||
'Yesterday',
|
||||
);
|
||||
});
|
||||
|
||||
test('older days use a locale date, not today/yesterday', () {
|
||||
final label = chatDayLabel(
|
||||
DateTime(2026, 6, 10),
|
||||
now: now,
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
localeCode: 'en',
|
||||
);
|
||||
expect(label, isNot(anyOf('Today', 'Yesterday')));
|
||||
expect(label, contains('10')); // the day number
|
||||
});
|
||||
|
||||
test('a previous year is shown', () {
|
||||
final label = chatDayLabel(
|
||||
DateTime(2024, 6, 10),
|
||||
now: now,
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
localeCode: 'en',
|
||||
);
|
||||
expect(label, contains('2024'));
|
||||
});
|
||||
|
||||
test('respects the locale (Spanish month name)', () {
|
||||
final label = chatDayLabel(
|
||||
DateTime(2026, 6, 10),
|
||||
now: now,
|
||||
today: 'Hoy',
|
||||
yesterday: 'Ayer',
|
||||
localeCode: 'es',
|
||||
);
|
||||
expect(label.toLowerCase(), contains('junio'));
|
||||
});
|
||||
});
|
||||
|
||||
group('chatBubbleTime', () {
|
||||
test('English uses a 12-hour clock', () {
|
||||
final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'en');
|
||||
expect(s.toUpperCase(), contains('PM'));
|
||||
});
|
||||
|
||||
test('Spanish uses a 24-hour clock', () {
|
||||
final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'es');
|
||||
expect(s, contains('14'));
|
||||
expect(s.toUpperCase(), isNot(contains('PM')));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue