feat: remember show images per sender (#378)
## Summary Closes #377 - Adds a new `ImageTrustedSenders` Drift table (schema v37) that stores email addresses for which remote images are loaded automatically (per device, not per account) - When the user taps "Load remote images", the sender's address is saved and a 3-second snackbar appears with a "Settings" hyperlink to undo the choice in preferences - Both `EmailDetailScreen` and `ThreadDetailScreen` check the trusted senders list on open and auto-load images for known senders - The Preferences screen gains a new "Trusted image senders" section listing all saved senders with individual remove buttons ## Test plan - [x] `dart run build_runner build` regenerates `database.g.dart` cleanly (schema v37) - [x] `flutter analyze` — no issues - [x] Migration test updated: checks `image_trusted_senders` table exists after upgrade and fresh install - [x] `FakeUserPreferencesRepository` updated with three new interface methods - [x] All 490 unit + widget tests pass (1 pre-existing golden test failure unrelated to this change) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/378
This commit was merged in pull request #378.
This commit is contained in:
committed by
guettli
co-authored by
guettli
Thomas SharedInbox
parent
5e029a1365
commit
692fa14d4d
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 36;
|
||||
const int dbSchemaVersion = 37;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -247,3 +247,10 @@ final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||
) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
final trustedImageSendersProvider =
|
||||
StreamProvider.autoDispose<List<String>>((ref) {
|
||||
return ref
|
||||
.watch(userPreferencesRepositoryProvider)
|
||||
.observeTrustedImageSenders();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,11 +627,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 +658,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