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:
vjrj 2026-07-11 06:39:39 +02:00
parent 44337497d0
commit 68b04ea409
27 changed files with 1793 additions and 264 deletions

View 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')));
});
});
}