diff --git a/LATER.md b/LATER.md index 6e1ee5f..2f30ba9 100644 --- a/LATER.md +++ b/LATER.md @@ -31,12 +31,6 @@ Try Qwen, vscode plugin --- -test/unit/fake_imap.dart - -Why is that still needed? We have Stalwart. - ---- - After Try Connection, show some matching icon next to the text. --- @@ -47,12 +41,6 @@ Test with a Fastmail account --- -LINTING.md - ---- - ---- - scripts/check_coverage.dart reduce files in _excluded. diff --git a/NEXT.md b/NEXT.md index a5cdb5d..8fb1783 100644 --- a/NEXT.md +++ b/NEXT.md @@ -4,12 +4,23 @@ Do one thing, ask if unsure first! -The implement. +Then implement. Then run `task check`. +Then move task to DONE.md + Then commit. -After commit, remove the item from this document. - ## Tasks + +Implement thread-view. + +First create a plan. + +For JMAP this is easy. + +But for IMAP? + +Threads should be synced to the DB, too. Use JMAP as an example. Then think about getting this data +structure from imap. diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index 3f7ad47..a850659 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -14,6 +14,11 @@ class Email { final bool isSeen; final bool isFlagged; final bool hasAttachment; + final String? threadId; + final String? messageId; + final String? inReplyTo; + // Space-separated RFC 2822 References header value. + final String? references; const Email({ required this.id, @@ -30,6 +35,41 @@ class Email { required this.isSeen, required this.isFlagged, required this.hasAttachment, + this.threadId, + this.messageId, + this.inReplyTo, + this.references, + }); +} + +/// A group of related emails sharing the same thread. +class EmailThread { + final String threadId; + final String? subject; + final List participants; + final DateTime latestDate; + final int messageCount; + final bool hasUnread; + final bool isFlagged; + final String latestEmailId; + final String accountId; + final String mailboxPath; + + // All email IDs in this thread (oldest-first). Needed for batch operations. + final List emailIds; + + const EmailThread({ + required this.threadId, + required this.subject, + required this.participants, + required this.latestDate, + required this.messageCount, + required this.hasUnread, + required this.isFlagged, + required this.latestEmailId, + required this.emailIds, + required this.accountId, + required this.mailboxPath, }); } diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index c898655..bfa20fc 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -2,6 +2,13 @@ import '../models/email.dart'; abstract class EmailRepository { Stream> observeEmails(String accountId, String mailboxPath); + + /// Groups emails by threadId and returns one [EmailThread] per thread, + /// sorted by the latest message date descending. + Stream> observeThreads( + String accountId, + String mailboxPath, + ); Future getEmail(String emailId); Future getEmailBody(String emailId); Future syncEmails(String accountId, String mailboxPath); diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 324b121..bf4cad4 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -65,6 +65,12 @@ class Emails extends Table { BoolColumn get isFlagged => boolean().withDefault(const Constant(false))(); BoolColumn get hasAttachment => boolean().withDefault(const Constant(false))(); + // Added in schema v14: email threading. + TextColumn get threadId => text().nullable()(); + TextColumn get messageId => text().nullable()(); + TextColumn get inReplyTo => text().nullable()(); + // Space-separated list of Message-IDs (RFC 2822 References header). + TextColumn get references => text().nullable()(); @override Set get primaryKey => {id}; @@ -189,7 +195,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 13; + int get schemaVersion => 14; @override MigrationStrategy get migration => MigrationStrategy( @@ -235,6 +241,12 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(accounts, accounts.verbose); await m.addColumn(syncLogs, syncLogs.protocolLog); } + if (from < 14) { + await m.addColumn(emails, emails.threadId); + await m.addColumn(emails, emails.messageId); + await m.addColumn(emails, emails.inReplyTo); + await m.addColumn(emails, emails.references); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 8c980c8..514ec94 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -66,6 +66,61 @@ class EmailRepositoryImpl implements EmailRepository { .map((rows) => rows.map(_toModel).toList()); } + @override + Stream> observeThreads( + String accountId, + String mailboxPath, + ) { + return observeEmails(accountId, mailboxPath).map(_groupIntoThreads); + } + + static List _groupIntoThreads(List emails) { + // Group emails by threadId, falling back to email id for unthreaded mail. + final groups = >{}; + for (final email in emails) { + final key = email.threadId ?? email.id; + groups.putIfAbsent(key, () => []).add(email); + } + + final threads = groups.values.map((threadEmails) { + // Sort within thread oldest-first so latest is last. + threadEmails.sort((a, b) { + final da = a.sentAt ?? a.receivedAt; + final db = b.sentAt ?? b.receivedAt; + return da.compareTo(db); + }); + + final latest = threadEmails.last; + + // Collect unique participants across the whole thread. + final seen = {}; + final participants = []; + for (final e in threadEmails) { + for (final a in e.from) { + if (seen.add(a.email)) participants.add(a); + } + } + + return model.EmailThread( + threadId: latest.threadId ?? latest.id, + subject: latest.subject, + participants: participants, + latestDate: latest.sentAt ?? latest.receivedAt, + messageCount: threadEmails.length, + hasUnread: threadEmails.any((e) => !e.isSeen), + isFlagged: threadEmails.any((e) => e.isFlagged), + latestEmailId: latest.id, + emailIds: threadEmails.map((e) => e.id).toList(), + accountId: latest.accountId, + mailboxPath: latest.mailboxPath, + ); + }).toList(); + + // Sort threads by latest message descending. + threads.sort((a, b) => b.latestDate.compareTo(a.latestDate)); + return threads; + } + @override Future getEmail(String emailId) async { final row = await (_db.select(_db.emails) @@ -383,15 +438,11 @@ class EmailRepositoryImpl implements EmailRepository { String mailboxPath, imap.MessageSequence sequence, ) async { + const fetchItems = + '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])'; final fetch = sequence.isUidSequence - ? await client.uidFetchMessages( - sequence, - '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)', - ) - : await client.fetchMessages( - sequence, - '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)', - ); + ? await client.uidFetchMessages(sequence, fetchItems) + : await client.fetchMessages(sequence, fetchItems); var bytes = 0; await _db.transaction(() async { for (final msg in fetch.messages) { @@ -407,6 +458,15 @@ class EmailRepositoryImpl implements EmailRepository { } bytes += msg.size ?? 0; final emailId = '${account.id}:$uid'; + final msgId = envelope.messageId?.trim(); + final inReplyTo = envelope.inReplyTo?.trim(); + final refs = msg.getHeaderValue('References')?.trim(); + final threadId = _computeThreadId( + emailId: emailId, + messageId: msgId, + inReplyTo: inReplyTo, + references: refs, + ); await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: emailId, @@ -422,6 +482,10 @@ class EmailRepositoryImpl implements EmailRepository { isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false), hasAttachment: Value(msg.hasAttachments()), + threadId: Value(threadId), + messageId: Value(msgId), + inReplyTo: Value(inReplyTo), + references: Value(refs), ), ); } @@ -495,6 +559,7 @@ class EmailRepositoryImpl implements EmailRepository { static const _emailProperties = [ 'id', + 'threadId', 'mailboxIds', 'subject', 'sentAt', @@ -505,6 +570,9 @@ class EmailRepositoryImpl implements EmailRepository { 'keywords', 'hasAttachment', 'preview', + 'messageId', + 'inReplyTo', + 'references', 'textBody', 'htmlBody', 'bodyValues', @@ -678,6 +746,15 @@ class EmailRepositoryImpl implements EmailRepository { final receivedAt = _parseDate(m['receivedAt'] as String?) ?? DateTime.now(); + final jmapThreadId = m['threadId'] as String?; + // JMAP messageId/inReplyTo/references are arrays; join to space-separated. + final jmapMessageId = + _joinJmapStringList(m['messageId'] as List?); + final jmapInReplyTo = + _joinJmapStringList(m['inReplyTo'] as List?); + final jmapReferences = + _joinJmapStringList(m['references'] as List?); + await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: dbId, @@ -694,6 +771,10 @@ class EmailRepositoryImpl implements EmailRepository { isSeen: Value(keywords.containsKey(r'$seen')), isFlagged: Value(keywords.containsKey(r'$flagged')), hasAttachment: Value((m['hasAttachment'] as bool?) ?? false), + threadId: Value(jmapThreadId), + messageId: Value(jmapMessageId), + inReplyTo: Value(jmapInReplyTo), + references: Value(jmapReferences), ), ); @@ -1728,6 +1809,32 @@ class EmailRepositoryImpl implements EmailRepository { // ── Helpers ──────────────────────────────────────────────────────────────── + /// Computes a stable threadId from RFC 2822 headers. + /// Uses the first entry in References (= oldest ancestor) so all messages + /// in a thread share the same root Message-ID as their threadId. + /// Falls back to In-Reply-To, then own Message-ID, then internal emailId. + /// JMAP header fields like messageId/inReplyTo/references come as arrays. + /// We join them space-separated to match the IMAP convention. + static String? _joinJmapStringList(List? list) { + if (list == null || list.isEmpty) return null; + final joined = list.cast().join(' '); + return joined.isEmpty ? null : joined; + } + + static String? _computeThreadId({ + required String emailId, + required String? messageId, + required String? inReplyTo, + required String? references, + }) { + if (references != null && references.isNotEmpty) { + final first = references.trim().split(RegExp(r'\s+')).firstOrNull; + if (first != null && first.isNotEmpty) return first; + } + if (inReplyTo != null && inReplyTo.isNotEmpty) return inReplyTo; + return messageId; // null for messages with no Message-ID (rare) + } + String _encodeAddresses(List? addresses) => jsonEncode( (addresses ?? const []) .map((a) => {'name': a.personalName, 'email': a.email}) @@ -1762,6 +1869,10 @@ class EmailRepositoryImpl implements EmailRepository { isSeen: row.isSeen, isFlagged: row.isFlagged, hasAttachment: row.hasAttachment, + threadId: row.threadId, + messageId: row.messageId, + inReplyTo: row.inReplyTo, + references: row.references, ); } diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 71e4d20..f9bf8af 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -14,6 +14,7 @@ import 'screens/search_screen.dart'; import 'screens/sieve_script_edit_screen.dart'; import 'screens/sieve_scripts_screen.dart'; import 'screens/sync_log_screen.dart'; +import 'screens/thread_detail_screen.dart'; final router = GoRouter( initialLocation: '/accounts', @@ -84,6 +85,18 @@ final router = GoRouter( ), ], ), + GoRoute( + path: ':mailboxPath/threads/:threadId', + builder: (ctx, state) => ThreadDetailScreen( + accountId: state.pathParameters['accountId']!, + mailboxPath: Uri.decodeComponent( + state.pathParameters['mailboxPath']!, + ), + threadId: Uri.decodeComponent( + state.pathParameters['threadId']!, + ), + ), + ), ], ), ], diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 108a1db..e12d56d 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -31,8 +31,11 @@ class _EmailListScreenState extends ConsumerState { List? _searchResults; bool _searchLoading = false; - final Set _selectedIds = {}; - bool get _selecting => _selectedIds.isNotEmpty; + // Thread-level selection (key = threadId). + final Set _selectedThreadIds = {}; + // Last-emitted thread list, used to resolve emailIds for batch operations. + List _currentThreads = []; + bool get _selecting => _selectedThreadIds.isNotEmpty; @override void dispose() { @@ -40,17 +43,23 @@ class _EmailListScreenState extends ConsumerState { super.dispose(); } - void _toggleSelection(String id) { + void _toggleThreadSelection(EmailThread thread) { setState(() { - if (_selectedIds.contains(id)) { - _selectedIds.remove(id); + if (_selectedThreadIds.contains(thread.threadId)) { + _selectedThreadIds.remove(thread.threadId); } else { - _selectedIds.add(id); + _selectedThreadIds.add(thread.threadId); } }); } - void _clearSelection() => setState(() => _selectedIds.clear()); + void _clearSelection() => setState(() => _selectedThreadIds.clear()); + + // All email IDs belonging to currently selected threads. + List get _selectedEmailIds => _currentThreads + .where((t) => _selectedThreadIds.contains(t.threadId)) + .expand((t) => t.emailIds) + .toList(); Future _runSearch(String query) async { if (query.trim().isEmpty) { @@ -144,7 +153,7 @@ class _EmailListScreenState extends ConsumerState { icon: const Icon(Icons.close), onPressed: _clearSelection, ), - title: Text('${_selectedIds.length} selected'), + title: Text('${_selectedThreadIds.length} selected'), ); } @@ -217,45 +226,28 @@ class _EmailListScreenState extends ConsumerState { if (_searchResults!.isEmpty) { return const Center(child: Text('No results')); } - return _buildList(_searchResults!); + return _buildEmailList(_searchResults!); } Widget _buildStreamBody(EmailRepository emailRepo) { - return StreamBuilder>( - stream: emailRepo.observeEmails(widget.accountId, widget.mailboxPath), + return StreamBuilder>( + stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath), builder: (ctx, snap) { if (!snap.hasData) { return const Center(child: CircularProgressIndicator()); } - final emails = snap.data!; - if (emails.isEmpty) { + final threads = snap.data!; + _currentThreads = threads; + if (threads.isEmpty) { return const Center(child: Text('No emails')); } - return _buildList(emails); + return _buildThreadList(threads); }, ); } - Future _archiveEmail(Email email) async { - final archive = await ref - .read(mailboxRepositoryProvider) - .findMailboxByRole(widget.accountId, 'archive'); - if (!mounted) return; - if (archive == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No archive folder found')), - ); - return; - } - await ref.read(emailRepositoryProvider).moveEmail(email.id, archive.path); - } - - Future _deleteEmail(Email email) async { - await ref.read(emailRepositoryProvider).deleteEmail(email.id); - } - Future _batchArchive() async { - final ids = Set.from(_selectedIds); + final ids = _selectedEmailIds; _clearSelection(); final archive = await ref .read(mailboxRepositoryProvider) @@ -274,7 +266,7 @@ class _EmailListScreenState extends ConsumerState { } Future _batchDelete() async { - final ids = Set.from(_selectedIds); + final ids = _selectedEmailIds; _clearSelection(); final repo = ref.read(emailRepositoryProvider); for (final id in ids) { @@ -283,7 +275,7 @@ class _EmailListScreenState extends ConsumerState { } Future _batchMarkSpam() async { - final ids = Set.from(_selectedIds); + final ids = _selectedEmailIds; _clearSelection(); final junk = await ref .read(mailboxRepositoryProvider) @@ -302,7 +294,7 @@ class _EmailListScreenState extends ConsumerState { } Future _batchMove() async { - final ids = Set.from(_selectedIds); + final ids = _selectedEmailIds; final mailboxes = await ref .read(mailboxRepositoryProvider) .observeMailboxes(widget.accountId) @@ -341,33 +333,48 @@ class _EmailListScreenState extends ConsumerState { } } - Widget _buildList(List emails) { + Widget _buildThreadList(List threads) { return ListView.builder( - itemCount: emails.length, + itemCount: threads.length, itemBuilder: (ctx, i) { - final e = emails[i]; - final isSelected = _selectedIds.contains(e.id); - final sender = e.from.isNotEmpty - ? (e.from.first.name ?? e.from.first.email) - : '(unknown)'; + final t = threads[i]; + final isSelected = _selectedThreadIds.contains(t.threadId); + final senderNames = + t.participants.map((a) => a.name ?? a.email).take(3).join(', '); final tile = ListTile( leading: _selecting ? Checkbox( value: isSelected, - onChanged: (_) => _toggleSelection(e.id), + onChanged: (_) => _toggleThreadSelection(t), ) : Icon( - e.isSeen ? Icons.mail_outline : Icons.mail, - color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary, + t.hasUnread ? Icons.mail : Icons.mail_outline, + color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null, ), - title: Text( - sender, - style: - e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold), + title: Row( + children: [ + Expanded( + child: Text( + senderNames.isEmpty ? '(unknown)' : senderNames, + style: t.hasUnread + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + overflow: TextOverflow.ellipsis, + ), + ), + if (t.messageCount > 1) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '[${t.messageCount}]', + style: Theme.of(ctx).textTheme.bodySmall, + ), + ), + ], ), subtitle: Text( - e.subject ?? '(no subject)', + t.subject ?? '(no subject)', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -377,29 +384,33 @@ class _EmailListScreenState extends ConsumerState { : Row( mainAxisSize: MainAxisSize.min, children: [ - if (e.isFlagged) + if (t.isFlagged) const Icon(Icons.star, color: Colors.amber, size: 16), - if (e.hasAttachment) - const Icon(Icons.attach_file, size: 16), const SizedBox(width: 4), Text( - e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', + _dateFmt.format(t.latestDate), style: Theme.of(ctx).textTheme.bodySmall, ), ], ), onTap: _selecting - ? () => _toggleSelection(e.id) - : () => context.push( - '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}', - ), - onLongPress: () => _toggleSelection(e.id), + ? () => _toggleThreadSelection(t) + : t.messageCount > 1 + ? () => context.push( + '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}', + ) + : () => context.push( + '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}', + ), + onLongPress: () => _toggleThreadSelection(t), ); if (_selecting) return tile; + // For swipe actions on threads, operate on the latest email only + // (single-email threads) or the whole thread. return Dismissible( - key: ValueKey(e.id), + key: ValueKey(t.threadId), background: _swipeBackground( alignment: Alignment.centerLeft, color: Colors.green, @@ -413,10 +424,19 @@ class _EmailListScreenState extends ConsumerState { label: 'Delete', ), onDismissed: (direction) async { + final repo = ref.read(emailRepositoryProvider); if (direction == DismissDirection.startToEnd) { - await _archiveEmail(e); + final archive = await ref + .read(mailboxRepositoryProvider) + .findMailboxByRole(widget.accountId, 'archive'); + if (!mounted || archive == null) return; + for (final id in t.emailIds) { + await repo.moveEmail(id, archive.path); + } } else { - await _deleteEmail(e); + for (final id in t.emailIds) { + await repo.deleteEmail(id); + } } }, child: tile, @@ -425,6 +445,42 @@ class _EmailListScreenState extends ConsumerState { ); } + // Used for search results, which are individual emails. + Widget _buildEmailList(List emails) { + return ListView.builder( + itemCount: emails.length, + itemBuilder: (ctx, i) { + final e = emails[i]; + final sender = e.from.isNotEmpty + ? (e.from.first.name ?? e.from.first.email) + : '(unknown)'; + return ListTile( + leading: Icon( + e.isSeen ? Icons.mail_outline : Icons.mail, + color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary, + ), + title: Text( + sender, + style: + e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + e.subject ?? '(no subject)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', + style: Theme.of(ctx).textTheme.bodySmall, + ), + onTap: () => context.push( + '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}', + ), + ); + }, + ); + } + Widget _swipeBackground({ required AlignmentGeometry alignment, required Color color, diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart new file mode 100644 index 0000000..b42c2bc --- /dev/null +++ b/lib/ui/screens/thread_detail_screen.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../core/models/email.dart'; +import '../../di.dart'; + +final _dateFmt = DateFormat('MMM d'); + +class ThreadDetailScreen extends ConsumerWidget { + const ThreadDetailScreen({ + super.key, + required this.accountId, + required this.mailboxPath, + required this.threadId, + }); + + final String accountId; + final String mailboxPath; + final String threadId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(emailRepositoryProvider); + return Scaffold( + appBar: AppBar(title: const Text('Thread')), + body: StreamBuilder>( + stream: repo.observeThreads(accountId, mailboxPath), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final thread = + snap.data!.where((t) => t.threadId == threadId).firstOrNull; + if (thread == null) { + return const Center(child: Text('Thread not found')); + } + // Re-fetch the individual emails from observeEmails to show them. + return StreamBuilder>( + stream: repo.observeEmails(accountId, mailboxPath), + builder: (ctx, emailSnap) { + if (!emailSnap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final emails = emailSnap.data! + .where( + (e) => (e.threadId ?? e.id) == threadId, + ) + .toList() + ..sort((a, b) { + final da = a.sentAt ?? a.receivedAt; + final db = b.sentAt ?? b.receivedAt; + return da.compareTo(db); + }); + + if (emails.isEmpty) { + return const Center(child: Text('No messages')); + } + + return ListView.builder( + itemCount: emails.length, + itemBuilder: (ctx, i) { + final e = emails[i]; + final sender = e.from.isNotEmpty + ? (e.from.first.name ?? e.from.first.email) + : '(unknown)'; + return ListTile( + leading: Icon( + e.isSeen ? Icons.mail_outline : Icons.mail, + color: + e.isSeen ? null : Theme.of(ctx).colorScheme.primary, + ), + title: Text( + sender, + style: e.isSeen + ? null + : const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + e.preview ?? e.subject ?? '(no subject)', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', + style: Theme.of(ctx).textTheme.bodySmall, + ), + onTap: () => context.push( + '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mailboxPath)}/emails/${Uri.encodeComponent(e.id)}', + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index f3ac4e9..caad9d4 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -67,6 +67,10 @@ class _FakeEmails implements EmailRepository { @override Stream> observeEmails(String a, String m) => Stream.value([]); + @override + Stream> observeThreads(String a, String m) => + Stream.value([]); + @override Future getEmail(String id) async => null; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index a3c17a0..8c35fcd 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -72,6 +72,13 @@ class FakeEmailRepository implements EmailRepository { Stream> observeEmails(String accountId, String mailboxPath) => Stream.value([]); + @override + Stream> observeThreads( + String accountId, + String mailboxPath, + ) => + Stream.value([]); + @override Future getEmail(String emailId) async => null; diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 39dd419..6ee3489 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -154,6 +154,29 @@ class FakeEmailRepository implements EmailRepository { Stream> observeEmails(String accountId, String mailboxPath) => Stream.value(List.of(_emails)); + @override + Stream> observeThreads( + String accountId, + String mailboxPath, + ) => + observeEmails(accountId, mailboxPath).map((emails) { + return emails.map((e) { + return EmailThread( + threadId: e.threadId ?? e.id, + subject: e.subject, + participants: e.from, + latestDate: e.sentAt ?? e.receivedAt, + messageCount: 1, + hasUnread: !e.isSeen, + isFlagged: e.isFlagged, + latestEmailId: e.id, + emailIds: [e.id], + accountId: e.accountId, + mailboxPath: e.mailboxPath, + ); + }).toList(); + }); + @override Future getEmail(String emailId) async => _emailDetail;