mirror of
https://github.com/krille-chan/fluffychat
synced 2024-09-10 13:05:11 +00:00
feat: implement session dump
Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
parent
ec74524234
commit
56ba2341f4
10 changed files with 203 additions and 5 deletions
|
@ -1304,6 +1304,15 @@
|
||||||
"count": {}
|
"count": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dehydrate": "Export session and wipe device",
|
||||||
|
"dehydrateWarning": "This action cannot be undone. Ensure you safely store the backup file.",
|
||||||
|
"dehydrateShare": "This is your private FluffyChat export. Ensure you don't lose it and keep it private.",
|
||||||
|
"dehydrateTor": "TOR Users: Export session",
|
||||||
|
"dehydrateTorLong": "For TOR users, it is recommended to export the session before closing the window.",
|
||||||
|
"hydrateTor": "TOR Users: Import session export",
|
||||||
|
"hydrateTorLong": "Did you export your session last time on TOR? Quickly import it and continue chatting.",
|
||||||
|
"hydrate": "Restore from backup file",
|
||||||
|
"advanced": "Advanced",
|
||||||
"loadingPleaseWait": "Loading… Please wait.",
|
"loadingPleaseWait": "Loading… Please wait.",
|
||||||
"@loadingPleaseWait": {
|
"@loadingPleaseWait": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -2752,6 +2761,8 @@
|
||||||
"@experimentalVideoCalls": {},
|
"@experimentalVideoCalls": {},
|
||||||
"emailOrUsername": "Email or username",
|
"emailOrUsername": "Email or username",
|
||||||
"@emailOrUsername": {},
|
"@emailOrUsername": {},
|
||||||
|
"indexedDbErrorTitle": "Private mode issues",
|
||||||
|
"indexedDbErrorLong": "The message storage is unfortunately not enabled in private mode by default.\nPlease visit\n - about:config\n - set dom.indexedDB.privateBrowsing.enabled to true\nOtherwise, it is not possible to run FluffyChat.",
|
||||||
"switchToAccount": "Switch to account {number}",
|
"switchToAccount": "Switch to account {number}",
|
||||||
"@switchToAccount": {
|
"@switchToAccount": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||||
|
@ -23,6 +24,10 @@ import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
|
||||||
import '../../utils/url_launcher.dart';
|
import '../../utils/url_launcher.dart';
|
||||||
import '../../widgets/matrix.dart';
|
import '../../widgets/matrix.dart';
|
||||||
import '../bootstrap/bootstrap_dialog.dart';
|
import '../bootstrap/bootstrap_dialog.dart';
|
||||||
|
import '../settings_account/settings_account.dart';
|
||||||
|
|
||||||
|
import 'package:fluffychat/utils/tor_stub.dart'
|
||||||
|
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart';
|
||||||
|
|
||||||
enum SelectMode { normal, share, select }
|
enum SelectMode { normal, share, select }
|
||||||
|
|
||||||
|
@ -143,6 +148,8 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool isTorBrowser = false;
|
||||||
|
|
||||||
SpacesEntry get activeSpacesEntry {
|
SpacesEntry get activeSpacesEntry {
|
||||||
final id = _activeSpacesEntry;
|
final id = _activeSpacesEntry;
|
||||||
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
|
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
|
||||||
|
@ -300,6 +307,9 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
searchServer = await Store().getItem(_serverStoreNamespace);
|
searchServer = await Store().getItem(_serverStoreNamespace);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_checkTorBrowser();
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -652,6 +662,15 @@ class ChatListController extends State<ChatList> with TickerProviderStateMixin {
|
||||||
void _hackyWebRTCFixForWeb() {
|
void _hackyWebRTCFixForWeb() {
|
||||||
Matrix.of(context).voipPlugin?.context = context;
|
Matrix.of(context).voipPlugin?.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkTorBrowser() async {
|
||||||
|
if (!kIsWeb) return;
|
||||||
|
final isTor = await TorBrowserDetector.isTorBrowser;
|
||||||
|
setState(() => isTorBrowser = isTor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dehydrate() =>
|
||||||
|
SettingsAccountController.dehydrateDevice(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EditBundleAction { addToBundle, removeFromBundle }
|
enum EditBundleAction { addToBundle, removeFromBundle }
|
||||||
|
|
|
@ -204,6 +204,23 @@ class _ChatListViewBodyState extends State<ChatListViewBody> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
AnimatedContainer(
|
||||||
|
height: widget.controller.isTorBrowser ? 64 : 0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
curve: Curves.bounceInOut,
|
||||||
|
decoration: const BoxDecoration(),
|
||||||
|
child: Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.vpn_key),
|
||||||
|
title: Text(L10n.of(context)!.dehydrateTor),
|
||||||
|
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
|
||||||
|
trailing: const Icon(Icons.chevron_right_outlined),
|
||||||
|
onTap: widget.controller.dehydrate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (widget.controller.isSearchMode)
|
if (widget.controller.isSearchMode)
|
||||||
_SearchTitle(
|
_SearchTitle(
|
||||||
title: L10n.of(context)!.chats,
|
title: L10n.of(context)!.chats,
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||||
|
import 'package:file_picker_cross/file_picker_cross.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||||
|
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart';
|
import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart';
|
||||||
import 'package:vrouter/vrouter.dart';
|
import 'package:vrouter/vrouter.dart';
|
||||||
|
@ -12,6 +18,9 @@ import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart';
|
||||||
import 'package:fluffychat/widgets/matrix.dart';
|
import 'package:fluffychat/widgets/matrix.dart';
|
||||||
import '../../utils/localized_exception_extension.dart';
|
import '../../utils/localized_exception_extension.dart';
|
||||||
|
|
||||||
|
import 'package:fluffychat/utils/tor_stub.dart'
|
||||||
|
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart';
|
||||||
|
|
||||||
class HomeserverPicker extends StatefulWidget {
|
class HomeserverPicker extends StatefulWidget {
|
||||||
const HomeserverPicker({Key? key}) : super(key: key);
|
const HomeserverPicker({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@ -33,6 +42,26 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
||||||
AppConfig.allowOtherHomeservers && benchmarkResults == null;
|
AppConfig.allowOtherHomeservers && benchmarkResults == null;
|
||||||
String searchTerm = '';
|
String searchTerm = '';
|
||||||
|
|
||||||
|
bool isTorBrowser = false;
|
||||||
|
|
||||||
|
Future<void> _checkTorBrowser() async {
|
||||||
|
if (!kIsWeb) return;
|
||||||
|
|
||||||
|
Hive.openBox('test').then((value) => null).catchError(
|
||||||
|
(e, s) async {
|
||||||
|
await showOkAlertDialog(
|
||||||
|
context: context,
|
||||||
|
title: L10n.of(context)!.indexedDbErrorTitle,
|
||||||
|
message: L10n.of(context)!.indexedDbErrorLong,
|
||||||
|
onWillPop: () async => false);
|
||||||
|
_checkTorBrowser();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final isTor = await TorBrowserDetector.isTorBrowser;
|
||||||
|
setState(() => isTorBrowser = isTor);
|
||||||
|
}
|
||||||
|
|
||||||
void _updateFocus() {
|
void _updateFocus() {
|
||||||
if (benchmarkResults == null) _loadHomeserverList();
|
if (benchmarkResults == null) _loadHomeserverList();
|
||||||
if (homeserverFocusNode.hasFocus) {
|
if (homeserverFocusNode.hasFocus) {
|
||||||
|
@ -139,6 +168,7 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
homeserverFocusNode.addListener(_updateFocus);
|
homeserverFocusNode.addListener(_updateFocus);
|
||||||
|
_checkTorBrowser();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,4 +177,20 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
||||||
Matrix.of(context).navigatorContext = context;
|
Matrix.of(context).navigatorContext = context;
|
||||||
return HomeserverPickerView(this);
|
return HomeserverPickerView(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> restoreBackup() async {
|
||||||
|
await showFutureLoadingDialog(
|
||||||
|
context: context,
|
||||||
|
future: () async {
|
||||||
|
try {
|
||||||
|
final file = await FilePickerCross.importFromStorage(
|
||||||
|
fileExtension: '.fluffybackup');
|
||||||
|
final client = Matrix.of(context).getLoginClient();
|
||||||
|
await client.importDump(file.toString());
|
||||||
|
Matrix.of(context).initMatrix();
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs().e('Future error:', e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,29 @@ class HomeserverPickerView extends StatelessWidget {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// display a prominent banner to import session for TOR browser
|
||||||
|
// users. This feature is just some UX sugar as TOR users are
|
||||||
|
// usually forced to logout as TOR browser is non-persistent
|
||||||
|
AnimatedContainer(
|
||||||
|
height: controller.isTorBrowser ? 64 : 0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
curve: Curves.bounceInOut,
|
||||||
|
decoration: const BoxDecoration(),
|
||||||
|
child: Material(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(bottom: Radius.circular(8)),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.vpn_key),
|
||||||
|
title: Text(L10n.of(context)!.hydrateTor),
|
||||||
|
subtitle: Text(L10n.of(context)!.hydrateTorLong),
|
||||||
|
trailing: const Icon(Icons.chevron_right_outlined),
|
||||||
|
onTap: controller.restoreBackup,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
@ -140,6 +163,30 @@ class HomeserverPickerView extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: Text(L10n.of(context)!.advanced),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: controller.isLoading
|
||||||
|
? () {}
|
||||||
|
: controller.restoreBackup,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
primary: Colors.white.withAlpha(200),
|
||||||
|
onPrimary: Colors.black,
|
||||||
|
shadowColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: controller.isLoading
|
||||||
|
? const LinearProgressIndicator()
|
||||||
|
: Text(L10n.of(context)!.hydrate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||||
|
import 'package:file_picker_cross/file_picker_cross.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:vrouter/vrouter.dart';
|
import 'package:vrouter/vrouter.dart';
|
||||||
|
|
||||||
|
@ -137,4 +142,36 @@ class SettingsAccountController extends State<SettingsAccount> {
|
||||||
});
|
});
|
||||||
return SettingsAccountView(this);
|
return SettingsAccountView(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> dehydrateAction() => dehydrateDevice(context);
|
||||||
|
|
||||||
|
static Future<void> dehydrateDevice(BuildContext context) async {
|
||||||
|
final response = await showOkCancelAlertDialog(
|
||||||
|
context: context,
|
||||||
|
isDestructiveAction: true,
|
||||||
|
title: L10n.of(context)!.dehydrate,
|
||||||
|
message: L10n.of(context)!.dehydrateWarning,
|
||||||
|
);
|
||||||
|
if (response != OkCancelResult.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await showFutureLoadingDialog(
|
||||||
|
context: context,
|
||||||
|
future: () async {
|
||||||
|
try {
|
||||||
|
final export = await Matrix.of(context).client.exportDump();
|
||||||
|
final filePickerCross = FilePickerCross(
|
||||||
|
Uint8List.fromList(const Utf8Codec().encode(export!)),
|
||||||
|
path:
|
||||||
|
'/fluffychat-export-${DateFormat(DateFormat.YEAR_MONTH_DAY).format(DateTime.now())}.fluffybackup',
|
||||||
|
fileExtension: 'fluffybackup');
|
||||||
|
await filePickerCross.exportToStorage(
|
||||||
|
subject: L10n.of(context)!.dehydrateShare,
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs().e('Export error', e, s);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,14 @@ class SettingsAccountView extends StatelessWidget {
|
||||||
onTap: controller.logoutAction,
|
onTap: controller.logoutAction,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
trailing: const Icon(Icons.tap_and_play),
|
||||||
|
title: Text(
|
||||||
|
L10n.of(context)!.dehydrate,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
onTap: controller.dehydrateAction,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
trailing: const Icon(Icons.delete_outlined),
|
trailing: const Icon(Icons.delete_outlined),
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
7
lib/utils/tor_stub.dart
Normal file
7
lib/utils/tor_stub.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/// Stub class for [TorBrowserDetector]
|
||||||
|
///
|
||||||
|
/// statically returns false as Tor **browser** can only be detected in a
|
||||||
|
/// **browser**.
|
||||||
|
abstract class TorBrowserDetector {
|
||||||
|
static Future<bool> get isTorBrowser => Future.value(false);
|
||||||
|
}
|
|
@ -291,7 +291,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.6"
|
version: "1.0.6"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
|
@ -1701,6 +1701,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
tor_detector_web:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: tor_detector_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
tuple:
|
tuple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -58,7 +58,7 @@ dependencies:
|
||||||
localstorage: ^4.0.0+1
|
localstorage: ^4.0.0+1
|
||||||
lottie: ^1.2.2
|
lottie: ^1.2.2
|
||||||
matrix: ^0.10.3
|
matrix: ^0.10.3
|
||||||
matrix_homeserver_recommendations: ^0.2.0
|
matrix_homeserver_recommendations: ^0.2.1
|
||||||
matrix_link_text: ^1.0.2
|
matrix_link_text: ^1.0.2
|
||||||
native_imaging:
|
native_imaging:
|
||||||
git: https://gitlab.com/famedly/company/frontend/libraries/native_imaging.git
|
git: https://gitlab.com/famedly/company/frontend/libraries/native_imaging.git
|
||||||
|
@ -67,7 +67,7 @@ dependencies:
|
||||||
pin_code_text_field: ^1.8.0
|
pin_code_text_field: ^1.8.0
|
||||||
provider: ^6.0.2
|
provider: ^6.0.2
|
||||||
punycode: ^1.0.0
|
punycode: ^1.0.0
|
||||||
qr_code_scanner: ^0.7.0
|
qr_code_scanner: ^1.0.0
|
||||||
qr_flutter: ^4.0.0
|
qr_flutter: ^4.0.0
|
||||||
receive_sharing_intent: ^1.4.5
|
receive_sharing_intent: ^1.4.5
|
||||||
record: ^4.1.1
|
record: ^4.1.1
|
||||||
|
@ -77,6 +77,7 @@ dependencies:
|
||||||
shared_preferences: ^2.0.13
|
shared_preferences: ^2.0.13
|
||||||
slugify: ^2.0.0
|
slugify: ^2.0.0
|
||||||
swipe_to_action: ^0.2.0
|
swipe_to_action: ^0.2.0
|
||||||
|
tor_detector_web: ^1.1.0
|
||||||
uni_links: ^0.5.1
|
uni_links: ^0.5.1
|
||||||
unifiedpush: ^4.0.0
|
unifiedpush: ^4.0.0
|
||||||
universal_html: ^2.0.8
|
universal_html: ^2.0.8
|
||||||
|
@ -126,14 +127,12 @@ flutter:
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
# Necessary for webRTC on web.
|
# Necessary for webRTC on web.
|
||||||
# Fix for stream fallback for unsupported browsers:
|
# Fix for stream fallback for unsupported browsers:
|
||||||
# https://github.com/fluttercommunity/plus_plugins/pull/746
|
|
||||||
# Upstream pull request: https://github.com/fluttercommunity/plus_plugins/pull/746
|
# Upstream pull request: https://github.com/fluttercommunity/plus_plugins/pull/746
|
||||||
connectivity_plus_web:
|
connectivity_plus_web:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/TheOneWithTheBraid/plus_plugins.git
|
url: https://github.com/TheOneWithTheBraid/plus_plugins.git
|
||||||
ref: a04401cb48abe92d138c0e9288b360739994a9e9
|
ref: a04401cb48abe92d138c0e9288b360739994a9e9
|
||||||
path: packages/connectivity_plus/connectivity_plus_web
|
path: packages/connectivity_plus/connectivity_plus_web
|
||||||
dbus: ^0.7.1
|
|
||||||
geolocator_android:
|
geolocator_android:
|
||||||
hosted:
|
hosted:
|
||||||
name: geolocator_android
|
name: geolocator_android
|
||||||
|
|
Loading…
Reference in a new issue