From 1e194175da03ef149731839a038a7c0db35a1d17 Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Sat, 5 Feb 2022 00:44:18 +0100 Subject: [PATCH] feat: propose homeserver based on response time - use `package:matrix_homeserver_recommendations` to benchmark homeservers - propose fastest server without anti-features as homeserver - display small button with server information - use Matrix.org / the default configuration as fallback Signed-off-by: TheOneWithTheBraid --- assets/l10n/intl_en.arb | 13 ++- .../homeserver_picker/homeserver_picker.dart | 94 +++++++++++++-- .../homeserver_picker_view.dart | 51 +++++++-- .../homeserver_picker/homeserver_tile.dart | 108 ++++++++++++++++++ pubspec.lock | 23 +++- pubspec.yaml | 1 + 6 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 lib/pages/homeserver_picker/homeserver_tile.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 494b7573..5951edc1 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2774,5 +2774,16 @@ "widgetName": "Name", "widgetUrlError": "This is not a valid URL.", "widgetNameError": "Please provide a display name.", - "errorAddingWidget": "Error adding the widget." + "errorAddingWidget": "Error adding the widget.", + "reportUser": "Report user", + "showAvailableHomeservers": "Show available homeservers (Advanced users)", + "antiFeatures": "Anti-features", + "noAntiFeaturesRecorded": "No anti features recorded", + "serverRules": "Server rules", + "jurisdiction": "Jurisdiction", + "selectServer": "Select server", + "responseTime": "Response time", + "openServerList": "Visit", + "reportServerListProblem": "Report list issue", + "serverListJoinMatrix": "Server list by joinMatrix.org" } diff --git a/lib/pages/homeserver_picker/homeserver_picker.dart b/lib/pages/homeserver_picker/homeserver_picker.dart index cf01587c..fabf6818 100644 --- a/lib/pages/homeserver_picker/homeserver_picker.dart +++ b/lib/pages/homeserver_picker/homeserver_picker.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_web_auth/flutter_web_auth.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart'; import 'package:universal_html/html.dart' as html; import 'package:vrouter/vrouter.dart'; @@ -16,6 +18,7 @@ import 'package:fluffychat/utils/famedlysdk_store.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/localized_exception_extension.dart'; +import 'homeserver_tile.dart'; class HomeserverPicker extends StatefulWidget { const HomeserverPicker({Key? key}) : super(key: key); @@ -25,14 +28,19 @@ class HomeserverPicker extends StatefulWidget { } class HomeserverPickerController extends State { - bool isLoading = false; - String domain = AppConfig.defaultHomeserver; - final TextEditingController homeserverController = - TextEditingController(text: AppConfig.defaultHomeserver); + bool isLoading = true; + + String? domain; + List? benchmarkResults; + + TextEditingController? homeserverController; + StreamSubscription? _intentDataStreamSubscription; String? error; Timer? _coolDown; + late HomeserverListProvider parser; + void setDomain(String domain) { this.domain = domain; _coolDown?.cancel(); @@ -67,6 +75,7 @@ class HomeserverPickerController extends State { void initState() { super.initState(); checkHomeserverAction(); + benchmarkHomeServers(); } @override @@ -84,10 +93,12 @@ class HomeserverPickerController extends State { Future checkHomeserverAction() async { _coolDown?.cancel(); if (_lastCheckedHomeserver == domain) return; - if (domain.isEmpty) throw L10n.of(context)!.changeTheHomeserver; + if (domain == null || domain!.isEmpty) { + throw L10n.of(context)!.changeTheHomeserver; + } var homeserver = domain; - if (!homeserver.startsWith('https://')) { + if (!homeserver!.startsWith('https://')) { homeserver = 'https://$homeserver'; } @@ -179,7 +190,7 @@ class HomeserverPickerController extends State { void signUpAction() => VRouter.of(context).to( 'signup', - queryParameters: {'domain': domain}, + queryParameters: {'domain': domain!}, ); @override @@ -187,6 +198,75 @@ class HomeserverPickerController extends State { Matrix.of(context).navigatorContext = context; return HomeserverPickerView(this); } + + Future benchmarkHomeServers() async { + try { + parser = JoinmatrixOrgParser(); + final homeserverList = await parser.fetchHomeservers(); + final benchmark = await HomeserverListProvider.benchmarkHomeserver( + homeserverList, + timeout: const Duration(seconds: 10), + // TODO: do not rely on the homeserver list information telling the server supports registration + ); + + if (benchmark.isEmpty) { + throw NullThrownError(); + } + + // trying to use server without anti-features + final goodServers = []; + final badServers = []; + for (final result in benchmark) { + if (result.homeserver.antiFeatures == null) { + goodServers.add(result); + } else { + badServers.add(result); + } + } + + goodServers.sort(); + badServers.sort(); + + benchmarkResults = List.from([...goodServers, ...badServers]); + + domain = benchmarkResults!.first.homeserver.baseUrl.host; + } on Exception catch (e, s) { + Logs().e('Homeserver benchmark failed', e, s); + domain = AppConfig.defaultHomeserver; + } finally { + homeserverController = TextEditingController(text: domain); + checkHomeserverAction(); + } + } + + Future showServerPicker() async { + final selection = await showModal( + context: context, + builder: (context) => SimpleDialog( + title: Text(L10n.of(context)!.changeTheHomeserver), + children: [ + ...benchmarkResults!.map( + (e) => HomeserverTile( + benchmark: e, + onSelect: () { + Navigator.of(context).pop(e.homeserver); + }, + ), + ), + const Divider(), + JoinMatrixAttributionTile(), + ]), + ); + if (selection is Homeserver) { + if (domain != selection.baseUrl.host) { + setState(() { + domain = selection.baseUrl.host; + homeserverController!.text = domain!; + }); + checkHomeserverAction(); + } + } + } } class IdentityProvider { diff --git a/lib/pages/homeserver_picker/homeserver_picker_view.dart b/lib/pages/homeserver_picker/homeserver_picker_view.dart index c79e84e7..b8d49e3c 100644 --- a/lib/pages/homeserver_picker/homeserver_picker_view.dart +++ b/lib/pages/homeserver_picker/homeserver_picker_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:animations/animations.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -25,18 +26,44 @@ class HomeserverPickerView extends StatelessWidget { child: Scaffold( appBar: AppBar( titleSpacing: 8, - title: DefaultAppBarSearchField( - prefixText: 'https://', - hintText: L10n.of(context)!.enterYourHomeserver, - searchController: controller.homeserverController, - suffix: const Icon(Icons.edit_outlined), - padding: EdgeInsets.zero, - onChanged: controller.setDomain, - readOnly: !AppConfig.allowOtherHomeservers, - onSubmit: (_) => controller.checkHomeserverAction(), - unfocusOnClear: false, - autocorrect: false, - labelText: L10n.of(context)!.homeserver, + title: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.vertical, + fillColor: Colors.transparent, + child: child, + ); + }, + child: controller.homeserverController == null + ? Center( + key: ValueKey(controller.homeserverController), + child: const CircularProgressIndicator(), + ) + : DefaultAppBarSearchField( + key: ValueKey(controller.homeserverController), + prefixIcon: IconButton( + icon: const Icon(Icons.format_list_numbered), + onPressed: controller.showServerPicker, + tooltip: L10n.of(context)!.showAvailableHomeservers, + ), + prefixText: 'https://', + hintText: L10n.of(context)!.enterYourHomeserver, + searchController: controller.homeserverController, + suffix: const Icon(Icons.edit_outlined), + padding: EdgeInsets.zero, + onChanged: controller.setDomain, + readOnly: !AppConfig.allowOtherHomeservers, + onSubmit: (_) => controller.checkHomeserverAction(), + unfocusOnClear: false, + autocorrect: false, + labelText: L10n.of(context)!.homeserver, + ), ), elevation: 0, ), diff --git a/lib/pages/homeserver_picker/homeserver_tile.dart b/lib/pages/homeserver_picker/homeserver_tile.dart new file mode 100644 index 00000000..39cceefa --- /dev/null +++ b/lib/pages/homeserver_picker/homeserver_tile.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart'; +import 'package:url_launcher/link.dart'; + +class HomeserverTile extends StatelessWidget { + final HomeserverBenchmarkResult benchmark; + final VoidCallback onSelect; + + const HomeserverTile( + {Key? key, required this.benchmark, required this.onSelect}) + : super(key: key); + @override + Widget build(BuildContext context) { + return ExpansionTile( + title: Text(benchmark.homeserver.baseUrl.host), + subtitle: benchmark.homeserver.description != null + ? Text(benchmark.homeserver.description!) + : null, + children: [ + benchmark.homeserver.antiFeatures != null && + benchmark.homeserver.antiFeatures!.isNotEmpty + ? ListTile( + leading: const Icon(Icons.thumb_down), + title: Text(benchmark.homeserver.antiFeatures!), + subtitle: Text(L10n.of(context)!.antiFeatures), + ) + : ListTile( + leading: const Icon(Icons.recommend), + title: Text(L10n.of(context)!.noAntiFeaturesRecorded), + ), + if (benchmark.homeserver.jurisdiction != null) + ListTile( + leading: const Icon(Icons.public), + title: Text(benchmark.homeserver.jurisdiction!), + subtitle: Text(L10n.of(context)!.jurisdiction), + ), + ListTile( + leading: const Icon(Icons.speed), + title: Text("${benchmark.responseTime!.inMilliseconds} ms"), + subtitle: Text(L10n.of(context)!.responseTime), + ), + ButtonBar( + /* spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, */ + children: [ + if (benchmark.homeserver.privacyPolicy != null) + Link( + uri: benchmark.homeserver.privacyPolicy!, + target: LinkTarget.blank, + builder: (context, callback) => TextButton( + onPressed: callback, + child: Text(L10n.of(context)!.privacy), + ), + ), + if (benchmark.homeserver.rules != null) + Link( + uri: benchmark.homeserver.rules!, + target: LinkTarget.blank, + builder: (context, callback) => TextButton( + onPressed: callback, + child: Text(L10n.of(context)!.serverRules), + ), + ), + OutlinedButton( + onPressed: onSelect.call, + child: Text(L10n.of(context)!.selectServer), + ), + ], + ), + ], + ); + } +} + +class JoinMatrixAttributionTile extends StatelessWidget { + final parser = JoinmatrixOrgParser(); + + JoinMatrixAttributionTile({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(L10n.of(context)!.serverListJoinMatrix), + subtitle: ButtonBar(children: [ + Link( + uri: parser.externalUri, + target: LinkTarget.blank, + builder: (context, callback) => TextButton( + onPressed: callback, + child: Text(L10n.of(context)!.openServerList), + ), + ), + Link( + uri: parser.errorReportUrl, + target: LinkTarget.blank, + builder: (context, callback) => TextButton( + onPressed: callback, + child: Text(L10n.of(context)!.reportServerListProblem), + ), + ), + ]), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6bef89ca..29d4e2a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -991,6 +991,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.5.3" + matrix_homeserver_recommendations: + dependency: "direct main" + description: + name: matrix_homeserver_recommendations + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" matrix_link_text: dependency: "direct main" description: @@ -1697,7 +1704,21 @@ packages: name: unifiedpush url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "4.0.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" universal_html: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ac3f7bc3..9e7678dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: localstorage: ^4.0.0+1 lottie: ^1.2.2 matrix: ^0.8.17 + matrix_homeserver_recommendations: ^0.2.0 matrix_link_text: ^1.0.2 native_imaging: git: https://gitlab.com/famedly/libraries/native_imaging.git