Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fdd9f5308 | ||
|
|
c522f8e45f | ||
|
|
754708f7e5 | ||
|
|
b909a712dc | ||
|
|
3b3d4566a3 | ||
|
|
f92f3debd7 | ||
|
|
692fa14d4d |
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 36;
|
||||
const int dbSchemaVersion = 37;
|
||||
|
||||
@@ -15,6 +15,10 @@ abstract class EmailRepository {
|
||||
int limit = 50,
|
||||
});
|
||||
|
||||
/// Returns threads from the INBOX mailbox of every account, sorted by latest
|
||||
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
|
||||
|
||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
|
||||
@@ -5,4 +5,8 @@ abstract class UserPreferencesRepository {
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||
|
||||
Stream<List<String>> observeTrustedImageSenders();
|
||||
Future<void> addTrustedImageSender(String senderEmail);
|
||||
Future<void> removeTrustedImageSender(String senderEmail);
|
||||
}
|
||||
|
||||
@@ -307,6 +307,17 @@ class LocalSieveApplied extends Table {
|
||||
Set<Column> get primaryKey => {accountId, messageId};
|
||||
}
|
||||
|
||||
/// Senders for whom remote images are loaded automatically.
|
||||
/// Per-device/per-user — not tied to any email account.
|
||||
@DataClassName('ImageTrustedSenderRow')
|
||||
class ImageTrustedSenders extends Table {
|
||||
TextColumn get senderEmail => text()();
|
||||
DateTimeColumn get addedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {senderEmail};
|
||||
}
|
||||
|
||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||
@DataClassName('UserPreferencesRow')
|
||||
class UserPreferences extends Table {
|
||||
@@ -345,6 +356,7 @@ class UserPreferences extends Table {
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -611,6 +623,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
if (from < 37) {
|
||||
await m.createTable(imageTrustedSenders);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,26 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||
final query = _db.select(_db.threads).join([
|
||||
innerJoin(
|
||||
_db.mailboxes,
|
||||
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||
),
|
||||
]);
|
||||
query
|
||||
..where(_db.mailboxes.role.equals('inbox'))
|
||||
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||
..limit(limit);
|
||||
return query.watch().map(
|
||||
(rows) => rows
|
||||
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||
List<model.EmailAddress> parseAddresses(String json) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
|
||||
@@ -50,6 +50,31 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<String>> observeTrustedImageSenders() {
|
||||
return (_db.select(_db.imageTrustedSenders)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
|
||||
.watch()
|
||||
.map((rows) => rows.map((r) => r.senderEmail).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
|
||||
ImageTrustedSendersCompanion(
|
||||
senderEmail: Value(senderEmail.toLowerCase()),
|
||||
addedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||
await (_db.delete(_db.imageTrustedSenders)
|
||||
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
|
||||
.go();
|
||||
}
|
||||
|
||||
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||
if (row == null) return const pref.UserPreferences();
|
||||
return pref.UserPreferences(
|
||||
|
||||
+35
@@ -211,10 +211,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
repo.getEmailBody(_emailId),
|
||||
]);
|
||||
unawaited(repo.setFlag(_emailId, seen: true));
|
||||
final header = results[0] as Email?;
|
||||
if (header != null) {
|
||||
unawaited(_prefetchNextEmailBody(repo, header));
|
||||
}
|
||||
return (results[0] as Email?, results[1] as EmailBody);
|
||||
}
|
||||
|
||||
Future<void> _prefetchNextEmailBody(
|
||||
EmailRepository repo,
|
||||
Email header,
|
||||
) async {
|
||||
final prefs = ref.read(userPreferencesProvider).value;
|
||||
final action =
|
||||
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||
if (action != AfterMailViewAction.nextMessage) return;
|
||||
|
||||
final threads =
|
||||
await repo.observeThreads(header.accountId, header.mailboxPath).first;
|
||||
final currentIndex = threads.indexWhere(
|
||||
(t) => t.emailIds.contains(_emailId),
|
||||
);
|
||||
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
|
||||
|
||||
final nextId = threads[currentIndex + 1].latestEmailId;
|
||||
await repo.getEmailBody(nextId);
|
||||
}
|
||||
}
|
||||
|
||||
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||
});
|
||||
|
||||
final accountByIdProvider =
|
||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||
@@ -247,3 +275,10 @@ final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||
) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
final trustedImageSendersProvider =
|
||||
StreamProvider.autoDispose<List<String>>((ref) {
|
||||
return ref
|
||||
.watch(userPreferencesRepositoryProvider)
|
||||
.observeTrustedImageSenders();
|
||||
});
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||
@@ -24,11 +25,15 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/accounts',
|
||||
initialLocation: '/inbox',
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => UndoShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/inbox',
|
||||
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/accounts',
|
||||
builder: (ctx, state) => const AccountListScreen(),
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
final _formattedDates = <int, String>{};
|
||||
|
||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||
|
||||
String _fmtDate(DateTime dt) =>
|
||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||
|
||||
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||
const CombinedInboxScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CombinedInboxScreen> createState() =>
|
||||
_CombinedInboxScreenState();
|
||||
}
|
||||
|
||||
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accountsAsync = ref.watch(allAccountsProvider);
|
||||
|
||||
return accountsAsync.when(
|
||||
loading: () => const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
body: Center(child: Text('Error: $e')),
|
||||
),
|
||||
data: (accounts) {
|
||||
if (accounts.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) context.go('/accounts');
|
||||
});
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final accountNames = {
|
||||
for (final a in accounts) a.id: a.displayName,
|
||||
};
|
||||
final showAccount = accounts.length > 1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(accounts),
|
||||
drawer: _buildDrawer(context, accounts),
|
||||
body: _buildBody(accountNames, showAccount),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/compose'),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||
return AppBar(
|
||||
title: const Text('Combined Inbox'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sync),
|
||||
tooltip: 'Sync all',
|
||||
onPressed: () {
|
||||
for (final a in accounts) {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||
child: Text(
|
||||
'sharedinbox.de',
|
||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.manage_accounts),
|
||||
title: const Text('Accounts'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/accounts');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add),
|
||||
title: const Text('Add account'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/add'));
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
for (final account in accounts)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.inbox),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text(account.email),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text('Preferences'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/preferences'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Undo Log'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/undo-log'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/about'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final accounts = ref.read(allAccountsProvider).value ?? [];
|
||||
for (final a in accounts) {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final threads = snap.data!;
|
||||
if (threads.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: Center(child: Text('No emails')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return _buildThreadList(threads, accountNames, showAccount);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreadList(
|
||||
List<EmailThread> threads,
|
||||
Map<String, String> accountNames,
|
||||
bool showAccount,
|
||||
) {
|
||||
final hasMore = threads.length == _limit;
|
||||
return ListView.builder(
|
||||
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == threads.length) {
|
||||
return TextButton(
|
||||
onPressed: () => setState(() => _limit += _pageSize),
|
||||
child: const Text('Load more'),
|
||||
);
|
||||
}
|
||||
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreadTile(
|
||||
BuildContext ctx,
|
||||
EmailThread t,
|
||||
Map<String, String> accountNames,
|
||||
bool showAccount,
|
||||
) {
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: Icon(
|
||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
||||
),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.hasUnread
|
||||
? const TextStyle(fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
),
|
||||
if (t.preview != null && t.preview!.isNotEmpty)
|
||||
Text(
|
||||
t.preview!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
if (showAccount)
|
||||
Text(
|
||||
accountNames[t.accountId] ?? t.accountId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (t.isFlagged)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_fmtDate(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${t.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${t.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
);
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||
background: _swipeBackground(
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Colors.green,
|
||||
icon: Icons.archive,
|
||||
label: 'Archive',
|
||||
),
|
||||
secondaryBackground: _swipeBackground(
|
||||
alignment: Alignment.centerRight,
|
||||
color: Colors.red,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSwipeDismissed(
|
||||
EmailThread t,
|
||||
DismissDirection direction,
|
||||
) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
final originalEmails = (await Future.wait(
|
||||
t.emailIds.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
final archive = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(t.accountId, 'archive');
|
||||
if (!mounted || archive == null) return;
|
||||
|
||||
for (final id in t.emailIds) {
|
||||
await repo.moveEmail(id, archive.path);
|
||||
}
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: t.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: t.mailboxPath,
|
||||
destinationMailboxPath: archive.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
return;
|
||||
}
|
||||
|
||||
String? lastDestPath;
|
||||
for (final id in t.emailIds) {
|
||||
lastDestPath = await repo.deleteEmail(id);
|
||||
}
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: t.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: t.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Widget _swipeBackground({
|
||||
required AlignmentGeometry alignment,
|
||||
required Color color,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
}) {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,19 +171,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||
data: (d) {
|
||||
final trusted =
|
||||
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||
return _buildBody(context, d.$1, d.$2, trusted);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
||||
Widget _buildBody(
|
||||
BuildContext ctx,
|
||||
Email? header,
|
||||
EmailBody body,
|
||||
List<String> trustedSenders,
|
||||
) {
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
final senderEmail = header?.from.isNotEmpty == true
|
||||
? header!.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||
if (hasHtml) ...[
|
||||
if (!_loadRemoteImages)
|
||||
if (!effectiveLoadImages)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
@@ -191,13 +207,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 18),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
||||
onPressed: () {
|
||||
setState(() => _loadRemoteImages = true);
|
||||
if (senderEmail != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(senderEmail),
|
||||
);
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'Settings',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push('/accounts/preferences'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
|
||||
@@ -113,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trustedSenders =
|
||||
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||
final senderEmail = widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
@@ -147,13 +155,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_expanded) _buildExpandedBody(),
|
||||
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpandedBody() {
|
||||
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
@@ -184,21 +192,48 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
final body = snapshot.data!;
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHtml) ...[
|
||||
if (!_loadRemoteImages)
|
||||
if (!effectiveLoadImages)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 16),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
onPressed: () {
|
||||
setState(() => _loadRemoteImages = true);
|
||||
if (senderEmail != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(senderEmail),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'Settings',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push('/accounts/preferences'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
|
||||
@@ -12,6 +12,7 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Preferences')),
|
||||
@@ -131,6 +132,45 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Trusted image senders',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Remote images are loaded automatically for these senders.',
|
||||
),
|
||||
),
|
||||
...trustedSendersAsync.when(
|
||||
loading: () => const [],
|
||||
error: (_, __) => const [],
|
||||
data: (senders) => senders.isEmpty
|
||||
? [
|
||||
const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('No trusted senders yet.'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
for (final sender in senders)
|
||||
ListTile(
|
||||
title: Text(sender),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.removeTrustedImageSender(sender),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ const _excluded = {
|
||||
'lib/ui/screens/add_account_screen.dart',
|
||||
'lib/ui/screens/address_emails_screen.dart',
|
||||
'lib/ui/screens/changelog_screen.dart',
|
||||
'lib/ui/screens/combined_inbox_screen.dart',
|
||||
'lib/ui/screens/compose_screen.dart',
|
||||
'lib/ui/screens/crash_screen.dart',
|
||||
'lib/ui/screens/edit_account_screen.dart',
|
||||
|
||||
@@ -186,6 +186,10 @@ class _FakeEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -81,6 +81,9 @@ class FakeEmailRepository implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -287,6 +287,17 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAllInboxThreads,
|
||||
[],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 36);
|
||||
expect(db.schemaVersion, 37);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -209,6 +209,9 @@ void main() {
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
// v37: image_trusted_senders table.
|
||||
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -412,12 +415,17 @@ void main() {
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
// v37: image_trusted_senders table.
|
||||
await db
|
||||
.customSelect('SELECT count(*) FROM image_trusted_senders')
|
||||
.get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
},
|
||||
);
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
||||
test('fresh install creates all tables at schemaVersion 37', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -445,6 +453,7 @@ void main() {
|
||||
'share_keys', // v31
|
||||
'local_sieve_applied', // v32
|
||||
'user_preferences', // v34
|
||||
'image_trusted_senders', // v37
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -473,6 +482,9 @@ void main() {
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
// v37: image_trusted_senders table.
|
||||
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,9 @@ class _FakeEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -102,6 +102,9 @@ class _CountingEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -109,6 +109,17 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAllInboxThreads,
|
||||
[],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
|
||||
@@ -245,6 +245,10 @@ class FakeEmailRepository implements EmailRepository {
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
@@ -627,11 +631,13 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
});
|
||||
List<String>? trustedImageSenders,
|
||||
}) : _trustedImageSenders = trustedImageSenders ?? [];
|
||||
|
||||
MenuPosition menuPosition;
|
||||
MenuPosition mailViewButtonPosition;
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
final List<String> _trustedImageSenders;
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
@@ -656,6 +662,23 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
||||
afterMailViewAction = action;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<String>> observeTrustedImageSenders() =>
|
||||
Stream.value(List.of(_trustedImageSenders));
|
||||
|
||||
@override
|
||||
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||
final normalized = senderEmail.toLowerCase();
|
||||
if (!_trustedImageSenders.contains(normalized)) {
|
||||
_trustedImageSenders.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||
_trustedImageSenders.remove(senderEmail.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||
|
||||
Reference in New Issue
Block a user