Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30052b4602 | ||
|
|
2b2c786998 | ||
|
|
01e2bb2954 |
+6
-3
@@ -408,7 +408,7 @@ tasks:
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
deploy-bugreport:
|
||||
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||
desc: Build and deploy the Go bugreport server to the webserver
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
@@ -417,11 +417,14 @@ tasks:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
|
||||
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
|
||||
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
|
||||
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
|
||||
|
||||
build-windows-release:
|
||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||
|
||||
@@ -21,7 +21,6 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
@@ -68,12 +67,6 @@ final router = GoRouter(
|
||||
path: 'preferences',
|
||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'trusted-senders',
|
||||
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||
highlightedSender: state.extra as String?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
|
||||
@@ -3,12 +3,20 @@ 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';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.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});
|
||||
@@ -22,31 +30,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
// Thread-level selection (key = threadId).
|
||||
final Set<String> _selectedThreadIds = {};
|
||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||
List<EmailThread> _currentThreads = [];
|
||||
|
||||
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
||||
|
||||
void _toggleThreadSelection(EmailThread thread) {
|
||||
setState(() {
|
||||
if (_selectedThreadIds.contains(thread.threadId)) {
|
||||
_selectedThreadIds.remove(thread.threadId);
|
||||
} else {
|
||||
_selectedThreadIds.add(thread.threadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
||||
|
||||
void _selectAll() {
|
||||
setState(
|
||||
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accountsAsync = ref.watch(allAccountsProvider);
|
||||
@@ -75,38 +58,18 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(accounts),
|
||||
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||
drawer: _buildDrawer(context, accounts),
|
||||
body: _buildBody(accountNames, showAccount),
|
||||
floatingActionButton: _selecting
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
onPressed: () => context.push('/compose'),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/compose'),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||
if (_selecting) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
title: Text('${_selectedThreadIds.length} selected'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: 'Select all',
|
||||
onPressed: _selectAll,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
title: const Text('Combined Inbox'),
|
||||
actions: [
|
||||
@@ -128,26 +91,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectionBottomBar() {
|
||||
return BottomAppBar(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive),
|
||||
tooltip: 'Archive',
|
||||
onPressed: _batchArchive,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: _batchDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
@@ -233,7 +176,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final threads = snap.data!;
|
||||
_currentThreads = threads;
|
||||
if (threads.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
@@ -265,33 +207,119 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
child: const Text('Load more'),
|
||||
);
|
||||
}
|
||||
final t = threads[i];
|
||||
return EmailThreadTile(
|
||||
thread: t,
|
||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||
isSelecting: _selecting,
|
||||
showAccount: showAccount,
|
||||
accountName: accountNames[t.accountId],
|
||||
onTap: _selecting
|
||||
? () => _toggleThreadSelection(t)
|
||||
: 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)}',
|
||||
),
|
||||
onLongPress: () => _toggleThreadSelection(t),
|
||||
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||
);
|
||||
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,
|
||||
@@ -342,81 +370,24 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
|
||||
// Group selected threads by accountId so we look up each account's archive once.
|
||||
final byAccount = <String, List<EmailThread>>{};
|
||||
for (final t in _currentThreads) {
|
||||
if (!_selectedThreadIds.contains(t.threadId)) continue;
|
||||
(byAccount[t.accountId] ??= []).add(t);
|
||||
}
|
||||
|
||||
_clearSelection();
|
||||
|
||||
for (final entry in byAccount.entries) {
|
||||
final accountId = entry.key;
|
||||
final threads = entry.value;
|
||||
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
|
||||
if (!mounted || archive == null) continue;
|
||||
|
||||
for (final t in threads) {
|
||||
final originalEmails = (await Future.wait(
|
||||
t.emailIds.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
for (final id in t.emailIds) {
|
||||
await repo.moveEmail(id, archive.path);
|
||||
}
|
||||
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: t.mailboxPath,
|
||||
destinationMailboxPath: archive.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchDelete() async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
final selectedThreads = _currentThreads
|
||||
.where((t) => _selectedThreadIds.contains(t.threadId))
|
||||
.toList();
|
||||
|
||||
_clearSelection();
|
||||
|
||||
for (final t in selectedThreads) {
|
||||
final originalEmails = (await Future.wait(
|
||||
t.emailIds.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +229,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
label: 'Settings',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/accounts/trusted-senders',
|
||||
extra: senderEmail,
|
||||
),
|
||||
context.push('/accounts/preferences'),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,11 +12,20 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
// Cache formatted dates by local calendar day so DateFormat.format is called
|
||||
// at most once per unique date rather than once per list item per rebuild.
|
||||
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 EmailListScreen extends ConsumerStatefulWidget {
|
||||
const EmailListScreen({
|
||||
super.key,
|
||||
@@ -679,83 +688,168 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
final t = threads[i];
|
||||
return EmailThreadTile(
|
||||
thread: t,
|
||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||
isSelecting: _selecting,
|
||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: _selecting
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => _toggleThreadSelection(t),
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
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: _selecting
|
||||
? () => _toggleThreadSelection(t)
|
||||
: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
'/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)}',
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
onLongPress: () => _toggleThreadSelection(t),
|
||||
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||
);
|
||||
|
||||
// For swipe actions on threads, operate on the latest email only
|
||||
// (single-email threads) or the whole thread.
|
||||
return Dismissible(
|
||||
key: ValueKey(t.threadId),
|
||||
direction:
|
||||
_selecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||
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) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final type = direction == DismissDirection.startToEnd
|
||||
? UndoType.move
|
||||
: UndoType.delete;
|
||||
|
||||
// Fetch full email data before moving/deleting.
|
||||
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(widget.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: widget.accountId,
|
||||
type: type,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
destinationMailboxPath: archive.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||
);
|
||||
} else {
|
||||
String? lastDestPath;
|
||||
for (final id in t.emailIds) {
|
||||
lastDestPath = await repo.deleteEmail(id);
|
||||
}
|
||||
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: widget.accountId,
|
||||
type: type,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: tile,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSwipeDismissed(
|
||||
EmailThread t,
|
||||
DismissDirection direction,
|
||||
) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final type = direction == DismissDirection.startToEnd
|
||||
? UndoType.move
|
||||
: UndoType.delete;
|
||||
|
||||
// Fetch full email data before moving/deleting.
|
||||
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(widget.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: widget.accountId,
|
||||
type: type,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: widget.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: widget.accountId,
|
||||
type: type,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: widget.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
// Used for search results, which are individual emails.
|
||||
Widget _buildEmailList(List<Email> emails) {
|
||||
return ListView.builder(
|
||||
@@ -783,4 +877,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,14 +217,11 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
label: 'Settings',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/accounts/trusted-senders',
|
||||
extra: senderEmail,
|
||||
),
|
||||
context.push('/accounts/preferences'),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
const TrustedImageSendersScreen({super.key, this.highlightedSender});
|
||||
|
||||
final String? highlightedSender;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||
body: trustedSendersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Error loading trusted senders')),
|
||||
data: (senders) {
|
||||
if (senders.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No addresses added yet. '
|
||||
'Tap "Load remote images" in an email to add the sender.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: senders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sender = senders[index];
|
||||
final isHighlighted = sender == highlightedSender;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
sender,
|
||||
style: isHighlighted
|
||||
? const TextStyle(fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.removeTrustedImageSender(sender),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
@@ -15,7 +14,6 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||
final trustedCount = trustedSendersAsync.value?.length ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Preferences')),
|
||||
@@ -215,16 +213,41 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Allowed addresses for images',
|
||||
'Trusted image senders',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Text(
|
||||
trustedCount == 0
|
||||
? 'No addresses added yet.'
|
||||
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
|
||||
subtitle: const Text(
|
||||
'Remote images are loaded automatically for these senders.',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/accounts/trusted-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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.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);
|
||||
|
||||
/// A swipeable list tile for an [EmailThread].
|
||||
///
|
||||
/// Handles the [Dismissible] wrapper (archive left, delete right) and
|
||||
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
|
||||
/// line with the account name — used in the combined-inbox view.
|
||||
class EmailThreadTile extends StatelessWidget {
|
||||
const EmailThreadTile({
|
||||
super.key,
|
||||
required this.thread,
|
||||
required this.isSelected,
|
||||
required this.isSelecting,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onDismissed,
|
||||
this.showAccount = false,
|
||||
this.accountName,
|
||||
});
|
||||
|
||||
final EmailThread thread;
|
||||
final bool isSelected;
|
||||
final bool isSelecting;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final Future<void> Function(DismissDirection) onDismissed;
|
||||
|
||||
/// When true, renders an extra subtitle line with [accountName].
|
||||
final bool showAccount;
|
||||
final String? accountName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = thread;
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: isSelecting
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => onTap(),
|
||||
)
|
||||
: Icon(
|
||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||
color:
|
||||
t.hasUnread ? Theme.of(context).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(context).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(context).textTheme.bodySmall,
|
||||
),
|
||||
if (showAccount && accountName != null)
|
||||
Text(
|
||||
accountName!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
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(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||
direction:
|
||||
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||
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: onDismissed,
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
static 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,6 @@ const _excluded = {
|
||||
'lib/data/repositories/user_preferences_repository_impl.dart',
|
||||
'lib/ui/screens/user_preferences_screen.dart',
|
||||
'lib/core/services/update_service.dart',
|
||||
'lib/ui/widgets/email_thread_tile.dart',
|
||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||
};
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module sharedinbox.de/bugreport
|
||||
|
||||
go 1.21
|
||||
+27
-11
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,10 +21,12 @@ import (
|
||||
// BugReport represents the data stored in report.json
|
||||
type BugReport struct {
|
||||
Description string `json:"description"`
|
||||
Email string `json:"email"`
|
||||
AboutInfo string `json:"about_info"`
|
||||
EmailData string `json:"email_data,omitempty"`
|
||||
SyncLog string `json:"sync_log,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
HashedIP string `json:"hashed_ip"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -70,6 +75,12 @@ func generateUUID() (string, error) {
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
|
||||
func hashIP(ip string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(ip))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Enable CORS so the web app (if applicable) can upload
|
||||
@@ -132,6 +143,20 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
emailData := r.FormValue("email_data")
|
||||
syncLog := r.FormValue("sync_log")
|
||||
|
||||
// Get IP address
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
// Check X-Forwarded-For if behind a proxy
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if len(parts) > 0 {
|
||||
ip = strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
hashedIP := hashIP(ip)
|
||||
|
||||
uuidVal, err := generateUUID()
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate UUID: %v", err)
|
||||
@@ -154,10 +179,12 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
// Write report.json
|
||||
report := BugReport{
|
||||
Description: description,
|
||||
Email: email,
|
||||
AboutInfo: aboutInfo,
|
||||
EmailData: emailData,
|
||||
SyncLog: syncLog,
|
||||
Timestamp: now,
|
||||
HashedIP: hashedIP,
|
||||
}
|
||||
|
||||
reportJSONPath := filepath.Join(reportDir, "report.json")
|
||||
@@ -178,17 +205,6 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Write contact email to mail.eml (kept separate from report.json to isolate PII)
|
||||
if email != "" {
|
||||
mailEmlPath := filepath.Join(reportDir, "mail.eml")
|
||||
err = os.WriteFile(mailEmlPath, []byte(email), 0600)
|
||||
if err != nil {
|
||||
log.Printf("Failed to write mail.eml: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save attachments
|
||||
form := r.MultipartForm
|
||||
files := form.File["attachments[]"]
|
||||
|
||||
Reference in New Issue
Block a user