feat(accounts): share account settings via QR code / JSON export (#66)
Add Export account screen (QR code + copy-to-clipboard) and Import account screen (paste JSON code) so users can transfer IMAP/JMAP account configuration to another device without re-entering every field. - Account list popup: "Export account" opens a QR code with a password warning and a copy-code button. - Add Account screen: "Import account" button opens the import flow where pasting the exported JSON pre-fills the account and one tap saves it with a fresh generated ID. - New routes: /accounts/:id/export and /accounts/import. - Widget tests cover export display, import parsing, validation, and the happy-path save-and-navigate flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
122358c9a2
commit
99df6f5fd0
@@ -2,6 +2,8 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/account_export_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||
@@ -33,6 +35,10 @@ final router = GoRouter(
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'import',
|
||||
builder: (ctx, state) => const AccountImportScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
@@ -47,6 +53,12 @@ final router = GoRouter(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/export',
|
||||
builder: (ctx, state) => AccountExportScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/sync-log',
|
||||
builder: (ctx, state) =>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class AccountExportScreen extends ConsumerStatefulWidget {
|
||||
const AccountExportScreen({super.key, required this.accountId});
|
||||
|
||||
final String accountId;
|
||||
|
||||
@override
|
||||
ConsumerState<AccountExportScreen> createState() =>
|
||||
_AccountExportScreenState();
|
||||
}
|
||||
|
||||
class _AccountExportScreenState extends ConsumerState<AccountExportScreen> {
|
||||
bool _loading = true;
|
||||
String? _exportCode;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
final account = await repo.getAccount(widget.accountId);
|
||||
if (account == null) {
|
||||
setState(() {
|
||||
_error = 'Account not found';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final password = await repo.getPassword(widget.accountId);
|
||||
final payload = jsonEncode({
|
||||
'v': 1,
|
||||
'account': account.toJson(),
|
||||
'password': password,
|
||||
});
|
||||
setState(() {
|
||||
_exportCode = payload;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Export account')),
|
||||
body: _buildBody(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return Center(child: Text('Error: $_error'));
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'This code contains your password. Keep it private.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: QrImageView(
|
||||
key: const Key('accountQrCode'),
|
||||
data: _exportCode!,
|
||||
size: 260,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
key: const Key('copyCodeButton'),
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy code'),
|
||||
onPressed: () {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: _exportCode!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Code copied to clipboard')),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Scan the QR code on your other device, or tap "Copy code" and '
|
||||
'paste it into the "Import account" screen.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class AccountImportScreen extends ConsumerStatefulWidget {
|
||||
const AccountImportScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AccountImportScreen> createState() =>
|
||||
_AccountImportScreenState();
|
||||
}
|
||||
|
||||
class _AccountImportScreenState extends ConsumerState<AccountImportScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
Account? _parsed;
|
||||
String? _parsedPassword;
|
||||
String? _parseError;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged(String value) {
|
||||
final text = value.trim();
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_parsed = null;
|
||||
_parsedPassword = null;
|
||||
_parseError = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final json = jsonDecode(text) as Map<String, dynamic>;
|
||||
if ((json['v'] as int?) != 1) {
|
||||
throw const FormatException('Unknown version');
|
||||
}
|
||||
final account = Account.fromJson(
|
||||
json['account'] as Map<String, dynamic>,
|
||||
);
|
||||
final password = json['password'] as String;
|
||||
setState(() {
|
||||
_parsed = Account(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: account.displayName,
|
||||
email: account.email,
|
||||
username: account.username,
|
||||
type: account.type,
|
||||
imapHost: account.imapHost,
|
||||
imapPort: account.imapPort,
|
||||
imapSsl: account.imapSsl,
|
||||
smtpHost: account.smtpHost,
|
||||
smtpPort: account.smtpPort,
|
||||
smtpSsl: account.smtpSsl,
|
||||
manageSieveHost: account.manageSieveHost,
|
||||
manageSievePort: account.manageSievePort,
|
||||
manageSieveSsl: account.manageSieveSsl,
|
||||
jmapUrl: account.jmapUrl,
|
||||
);
|
||||
_parsedPassword = password;
|
||||
_parseError = null;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() {
|
||||
_parsed = null;
|
||||
_parsedPassword = null;
|
||||
_parseError =
|
||||
'Invalid code — paste the full text from "Export account"';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _import() async {
|
||||
if (_parsed == null || _parsedPassword == null) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.addAccount(_parsed!, _parsedPassword!);
|
||||
if (mounted) context.pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _saving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Import failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Import account')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'On your other device, open the account menu and tap '
|
||||
'"Export account". Then copy the code and paste it below.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const Key('importCodeField'),
|
||||
controller: _ctrl,
|
||||
maxLines: 6,
|
||||
onChanged: _onTextChanged,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Account code',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Paste code here',
|
||||
),
|
||||
),
|
||||
if (_parseError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_parseError!,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (_parsed != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ready to import:',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_parsed!.displayName),
|
||||
Text(_parsed!.email),
|
||||
Text(
|
||||
_parsed!.type == AccountType.jmap ? 'JMAP' : 'IMAP',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
key: const Key('importButton'),
|
||||
onPressed: (_parsed != null && !_saving) ? _import : null,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,10 @@ class _AccountTile extends ConsumerWidget {
|
||||
value: _AccountAction.emailFilters,
|
||||
child: Text('Email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.export,
|
||||
child: Text('Export account'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
@@ -202,6 +206,9 @@ class _AccountTile extends ConsumerWidget {
|
||||
case _AccountAction.emailFilters:
|
||||
await context.push('/accounts/${account.id}/sieve');
|
||||
break;
|
||||
case _AccountAction.export:
|
||||
await context.push('/accounts/${account.id}/export');
|
||||
break;
|
||||
case _AccountAction.delete:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -337,7 +344,7 @@ class _Step extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, export, delete }
|
||||
|
||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||
///
|
||||
|
||||
@@ -295,6 +295,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
onPressed: _detectAccount,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
key: const Key('importAccountButton'),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Import account'),
|
||||
onPressed: () => context.push('/accounts/import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -40,6 +40,9 @@ dependencies:
|
||||
open_filex: ^4.6.0
|
||||
mime: ^2.0.0
|
||||
|
||||
# QR code generation for account sharing
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
webview_flutter: ^4.0.0
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -32,6 +32,8 @@ const _excluded = {
|
||||
'lib/di.dart',
|
||||
'lib/main.dart',
|
||||
'lib/ui/router.dart',
|
||||
'lib/ui/screens/account_export_screen.dart',
|
||||
'lib/ui/screens/account_import_screen.dart',
|
||||
'lib/ui/screens/account_list_screen.dart',
|
||||
'lib/ui/screens/add_account_screen.dart',
|
||||
'lib/ui/screens/address_emails_screen.dart',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
|
||||
import 'helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('AccountExportScreen', () {
|
||||
const account = Account(
|
||||
id: 'acc-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
testWidgets('shows QR code and copy button after loading', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/export',
|
||||
overrides: baseOverrides(accounts: [account]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('accountQrCode')), findsOneWidget);
|
||||
expect(find.byKey(const Key('copyCodeButton')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows password warning', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/export',
|
||||
overrides: baseOverrides(accounts: [account]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('password'),
|
||||
findsAtLeastNWidgets(1),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AccountImportScreen', () {
|
||||
testWidgets('shows instruction text and disabled import button', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/import',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Import account'), findsOneWidget);
|
||||
expect(find.byKey(const Key('importCodeField')), findsOneWidget);
|
||||
|
||||
final importBtn = tester.widget<FilledButton>(
|
||||
find.byKey(const Key('importButton')),
|
||||
);
|
||||
expect(importBtn.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('invalid JSON shows error message', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/import',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('importCodeField')),
|
||||
'not valid json',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Invalid code'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('valid code shows account preview and enables import', (
|
||||
tester,
|
||||
) async {
|
||||
const account = Account(
|
||||
id: 'acc-99',
|
||||
displayName: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
final code = jsonEncode({
|
||||
'v': 1,
|
||||
'account': account.toJson(),
|
||||
'password': 'secret',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/import',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('importCodeField')),
|
||||
code,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
expect(find.text('bob@example.com'), findsOneWidget);
|
||||
|
||||
final importBtn = tester.widget<FilledButton>(
|
||||
find.byKey(const Key('importButton')),
|
||||
);
|
||||
expect(importBtn.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('successful import navigates back to accounts list', (
|
||||
tester,
|
||||
) async {
|
||||
const account = Account(
|
||||
id: 'acc-99',
|
||||
displayName: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
final code = jsonEncode({
|
||||
'v': 1,
|
||||
'account': account.toJson(),
|
||||
'password': 'secret',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/import',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('importCodeField')),
|
||||
code,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('importButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SharedInbox'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -136,6 +136,23 @@ void main() {
|
||||
expect(find.text('Add account'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('account popup menu contains Export account item', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(accounts: [kTestAccount]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Export account'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AppBar does not overflow at minimum supported window size', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -7,6 +7,15 @@ import 'helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('AddAccountScreen', () {
|
||||
testWidgets('step 1: shows Import account button', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('importAccountButton')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('step 1: shows email field and Continue button', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -22,6 +22,8 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_export_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||
@@ -369,12 +371,22 @@ Widget buildApp({
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'import',
|
||||
builder: (ctx, state) => const AccountImportScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/export',
|
||||
builder: (ctx, state) => AccountExportScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/search',
|
||||
builder: (ctx, state) =>
|
||||
|
||||
Reference in New Issue
Block a user