diff --git a/lib/api/get-relays-meta.dart b/lib/api/get-relays-meta.dart new file mode 100644 index 0000000..c188115 --- /dev/null +++ b/lib/api/get-relays-meta.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:locus/utils/access-deeply-nested-key.dart'; +import 'package:locus/utils/nostr_fetcher/BasicNostrFetchSocket.dart'; +import 'package:locus/utils/nostr_fetcher/NostrSocket.dart'; +import 'package:nostr/nostr.dart'; + +const MIN_LENGTH = 5000; + +class RelaysMetaFetcher extends BasicNostrFetchSocket { + List meta = []; + + RelaysMetaFetcher({ + required super.relay, + super.timeout, + }); + + @override + void onEndOfStream() { + closeConnection(); + } + + @override + void onNostrEvent(final Message message) { + // Relay URL, canWrite and canRead are in message.tags + // Latencies are saved in content, separated per region + // with the following schema: + // [ + // [], + // [], + // [], + // ] + final event = message.message as Event; + + final relayMeta = RelayMeta.fromFetchedContent( + canWrite: event.tags[1][1] == "true", + canRead: event.tags[2][1] == "true", + relay: event.tags[0][1], + content: jsonDecode(event.content), + worldRegion: "eu-west", + ); + + meta.add(relayMeta); + } + + @override + void onError(error) { + closeConnection(); + } +} + +class RelayMeta { + final String relay; + final bool canWrite; + final bool canRead; + final String contactInfo; + final String description; + final String name; + + final List connectionLatencies; + final List readLatencies; + final List writeLatencies; + + final int maxMessageLength; + final int maxContentLength; + + final int minPowDifficulty; + final bool requiresPayment; + + const RelayMeta({ + required this.relay, + required this.canWrite, + required this.canRead, + required this.contactInfo, + required this.description, + required this.name, + required this.connectionLatencies, + required this.readLatencies, + required this.writeLatencies, + required this.maxMessageLength, + required this.maxContentLength, + required this.minPowDifficulty, + required this.requiresPayment, + }); + + factory RelayMeta.fromFetchedContent({ + required final Map content, + required final String relay, + required final bool canRead, + required final bool canWrite, + required final String worldRegion, + }) => + RelayMeta( + relay: relay, + canRead: canRead, + canWrite: canWrite, + name: adnk(content, "info.name") ?? relay, + contactInfo: adnk(content, "info.contact") ?? "", + description: adnk(content, "info.description") ?? "", + connectionLatencies: List.from( + adnk(content, "latency.$worldRegion.0") ?? []) + .where((value) => value != null) + .toList() + .cast(), + readLatencies: + List.from(adnk(content, "latency.$worldRegion.1") ?? []) + .where((value) => value != null) + .toList() + .cast(), + writeLatencies: + List.from(adnk(content, "latency.$worldRegion.2") ?? []) + .where((value) => value != null) + .toList() + .cast(), + maxContentLength: + adnk(content, "info.limitations.max_content_length") ?? + MIN_LENGTH, + maxMessageLength: + adnk(content, "info.limitations.max_message_length") ?? + MIN_LENGTH, + requiresPayment: + adnk(content, "info.limitations.payment_required") ?? + false, + minPowDifficulty: + adnk(content, "info.limitations.min_pow_difficulty") ?? + 0); + + bool get isSuitable => + canWrite && + canRead && + !requiresPayment && + minPowDifficulty == 0 && + maxContentLength >= MIN_LENGTH; + + // Calculate average latency, we use the average as we want extreme highs + // to be taken into account. + double get score { + if (connectionLatencies.isEmpty || + readLatencies.isEmpty || + writeLatencies.isEmpty) { + // If there is no data available, we don't know if the relay is fully intact + return double.infinity; + } + + // Each latency has it's own factor to give each of them a different weight + // Lower latency = better - Because of this + // a factor closer to 0 resembles a HIGHER weight + // We prioritize read latency as we want to be able to provide a fast app + // Lower score = better + return (connectionLatencies.average * 0.9 + + readLatencies.average * 0.5 + + writeLatencies.average) + + (maxContentLength - MIN_LENGTH) * 0.0001; + } +} + +// Values taken from https://github.com/dskvr/nostr-watch/blob/develop/src/components/relays/jobs/LoadSeed.vue#L91 +final REQUEST_DATA = NostrSocket.createNostrRequestData( + kinds: [30304], + limit: 1000, + from: DateTime.now().subtract(2.hours), + authors: ["b3b0d247f66bf40c4c9f4ce721abfe1fd3b7529fbc1ea5e64d5f0f8df3a4b6e6"], +); + +Future>> fetchRelaysMeta() async { + final fetcher = RelaysMetaFetcher( + relay: "wss://history.nostr.watch", + ); + await fetcher.connect(); + fetcher.addData( + Request( + generate64RandomHexChars(), + [ + REQUEST_DATA, + ], + ).serialize(), + ); + await fetcher.onComplete; + + return { + "meta": fetcher.meta, + }; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bf96f17..45c99ed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -110,6 +110,8 @@ } }, "relaySelectSheet_selectRandomRelays": "Select {count} random Nostr Relays", + "relaySelectSheet_loadingRelaysMeta": "Loading Nostr Relays information...", + "relaySelectSheet_hint": "Relays are sorted from best to worst in ascending order. The best relays are at the top.", "taskAction_start": "Start Task", "taskAction_started_title": "Task started", "taskAction_started_description": "Task started at {date}", diff --git a/lib/main.dart b/lib/main.dart index cd77f55..cb00750 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:locus/App.dart'; +import 'package:locus/api/get-relays-meta.dart'; import 'package:locus/screens/locations_overview_screen_widgets/LocationFetchers.dart'; import 'package:locus/services/app_update_service.dart'; import 'package:locus/services/current_location_service.dart'; diff --git a/lib/screens/LocationsOverviewScreen.dart b/lib/screens/LocationsOverviewScreen.dart index 48b07cf..d41315a 100644 --- a/lib/screens/LocationsOverviewScreen.dart +++ b/lib/screens/LocationsOverviewScreen.dart @@ -19,6 +19,7 @@ import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; +import 'package:locus/api/get-relays-meta.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/screens/ImportTaskSheet.dart'; import 'package:locus/screens/SettingsScreen.dart'; diff --git a/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart b/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart index a5bcdbc..b43590e 100644 --- a/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart +++ b/lib/screens/locations_overview_screen_widgets/ActiveSharesSheet.dart @@ -460,7 +460,7 @@ class _ActiveSharesSheetState extends State child: SizedBox.square(), ), Expanded( - flex: 8, + flex: 6, child: Center( child: Text( l10n.locationsOverview_activeShares_amount( diff --git a/lib/utils/access-deeply-nested-key.dart b/lib/utils/access-deeply-nested-key.dart new file mode 100644 index 0000000..51c73dd --- /dev/null +++ b/lib/utils/access-deeply-nested-key.dart @@ -0,0 +1,19 @@ +T? accessDeeplyNestedKey(final Map obj, final String path) { + dynamic result = obj; + + for (final subPath in path.split(".")) { + if (result is List) { + final index = int.tryParse(subPath)!; + + result = result[index]; + } else if (result.containsKey(subPath)) { + result = result[subPath]; + } else { + return null; + } + } + + return result as T; +} + +const adnk = accessDeeplyNestedKey; diff --git a/lib/utils/nostr_fetcher/NostrSocket.dart b/lib/utils/nostr_fetcher/NostrSocket.dart index 2d4ae28..d8e77b3 100644 --- a/lib/utils/nostr_fetcher/NostrSocket.dart +++ b/lib/utils/nostr_fetcher/NostrSocket.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:locus/constants/values.dart'; import 'package:locus/services/location_point_service.dart'; -import 'package:locus/services/task_service/index.dart'; import 'package:locus/services/task_service/mixins.dart'; import 'package:locus/utils/nostr_fetcher/BasicNostrFetchSocket.dart'; import 'package:locus/utils/nostr_fetcher/Socket.dart'; @@ -121,10 +120,12 @@ class NostrSocket extends BasicNostrFetchSocket { final int? limit, final DateTime? from, final DateTime? until, + final List? authors, }) => Filter( kinds: kinds, limit: limit, + authors: authors ?? [], since: from == null ? null : (from.millisecondsSinceEpoch / 1000).floor(), until: until == null diff --git a/lib/utils/nostr_fetcher/Socket.dart b/lib/utils/nostr_fetcher/Socket.dart index f22b893..b767499 100644 --- a/lib/utils/nostr_fetcher/Socket.dart +++ b/lib/utils/nostr_fetcher/Socket.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter_logs/flutter_logs.dart'; diff --git a/lib/widgets/RelaySelectSheet.dart b/lib/widgets/RelaySelectSheet.dart index 6ca5108..55488fd 100644 --- a/lib/widgets/RelaySelectSheet.dart +++ b/lib/widgets/RelaySelectSheet.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_logs/flutter_logs.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:locus/api/get-relays-meta.dart'; import 'package:locus/constants/spacing.dart'; import 'package:locus/constants/values.dart'; import 'package:locus/utils/load_status.dart'; @@ -69,8 +70,10 @@ class RelaySelectSheet extends StatefulWidget { } class _RelaySelectSheetState extends State { - List availableRelays = []; + final List availableRelays = []; + final Map relayMeta = {}; LoadStatus loadStatus = LoadStatus.loading; + final _searchController = TextEditingController(); late final DraggableScrollableController _sheetController; String _newValue = ''; @@ -83,7 +86,8 @@ class _RelaySelectSheetState extends State { @override void initState() { super.initState(); - fetchAvailableRelays(); + _fetchAvailableRelays(); + _fetchRelaysMeta(); widget.controller.addListener(rebuild); _searchController.addListener(() { @@ -160,7 +164,56 @@ class _RelaySelectSheetState extends State { setState(() {}); } - Future fetchAvailableRelays() async { + // Filters all relays whether they are suitable + void _filterRelaysFromMeta() { + if (relayMeta.isEmpty || availableRelays.isEmpty) { + return; + } + + final suitableRelays = relayMeta.values + .where((meta) => meta.isSuitable) + .map((meta) => meta.relay) + .toSet(); + + setState(() { + availableRelays.retainWhere(suitableRelays.contains); + availableRelays.sort( + (a, b) => relayMeta[a]!.score > relayMeta[b]!.score ? 1 : -1, + ); + loadStatus = LoadStatus.success; + }); + } + + Future _fetchRelaysMeta() async { + FlutterLogs.logInfo( + LOG_TAG, + "Relay Select Sheet", + "Fetching relays meta...", + ); + + try { + final relaysMetaDataRaw = + await withCache(fetchRelaysMeta, "relays-meta")(); + final relaysMetaData = relaysMetaDataRaw["meta"] as List; + final newRelays = Map.fromEntries( + relaysMetaData.map((meta) => MapEntry(meta.relay, meta)), + ); + + relayMeta.clear(); + relayMeta.addAll(newRelays); + _filterRelaysFromMeta(); + + setState(() {}); + } catch (error) { + FlutterLogs.logError( + LOG_TAG, + "Relay Select Sheet", + "Failed to fetch available relays: $error", + ); + } + } + + Future _fetchAvailableRelays() async { FlutterLogs.logInfo( LOG_TAG, "Relay Select Sheet", @@ -173,10 +226,12 @@ class _RelaySelectSheetState extends State { relays.shuffle(); - setState(() { - availableRelays = relays; - loadStatus = LoadStatus.success; - }); + availableRelays + ..clear() + ..addAll(relays); + _filterRelaysFromMeta(); + + setState(() {}); } catch (error) { FlutterLogs.logError( LOG_TAG, @@ -209,46 +264,86 @@ class _RelaySelectSheetState extends State { final allRelays = List.from( [...widget.controller.relays, ...uncheckedFoundRelays]); - final length = allRelays.length + (isValueNew ? 1 : 0); - return ListView.builder( controller: draggableController, - itemCount: length, + // Add 2 so we can show and widgets + itemCount: allRelays.length + 2, itemBuilder: (context, rawIndex) { - if (isValueNew && rawIndex == 0) { - return PlatformWidget( - material: (context, _) => ListTile( - title: Text( - l10n.addNewValueLabel(_newValue), - ), - leading: const Icon( - Icons.add, + if (rawIndex == 0) { + if (isValueNew) { + return PlatformWidget( + material: (context, _) => ListTile( + title: Text( + l10n.addNewValueLabel(_newValue), + ), + leading: const Icon( + Icons.add, + ), + onTap: () { + widget.controller.add(_searchController.value.text); + _searchController.clear(); + }, ), - onTap: () { - widget.controller.add(_searchController.value.text); - _searchController.clear(); - }, - ), - cupertino: (context, _) => CupertinoButton( - child: Text( - l10n.addNewValueLabel(_newValue), + cupertino: (context, _) => CupertinoButton( + child: Text( + l10n.addNewValueLabel(_newValue), + ), + onPressed: () { + widget.controller.add(_searchController.value.text); + _searchController.clear(); + }, ), - onPressed: () { - widget.controller.add(_searchController.value.text); - _searchController.clear(); - }, - ), - ); + ); + } + return Container(); + } + + if (rawIndex == 1) { + return loadStatus == LoadStatus.loading + ? Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 20, + child: PlatformCircularProgressIndicator(), + ), + const SizedBox(width: MEDIUM_SPACE), + Text(l10n.relaySelectSheet_loadingRelaysMeta), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Row( + children: [ + Icon( + context.platformIcons.info, + color: getCaptionTextStyle(context).color, + ), + const SizedBox(width: MEDIUM_SPACE), + Flexible( + child: Text( + l10n.relaySelectSheet_hint, + style: getCaptionTextStyle(context), + ), + ), + ], + ), + ); } - final index = isValueNew ? rawIndex - 1 : rawIndex; + final index = rawIndex - 2; final relay = allRelays[index]; + final meta = relayMeta[relay]; return PlatformWidget( material: (context, _) => CheckboxListTile( title: Text( relay.length >= 6 ? relay.substring(6) : relay, ), + subtitle: meta == null ? null : Text(meta.description), value: widget.controller.relays.contains(relay), onChanged: (newValue) { if (newValue == null) { @@ -266,6 +361,7 @@ class _RelaySelectSheetState extends State { title: Text( relay.length >= 6 ? relay.substring(6) : relay, ), + subtitle: meta == null ? null : Text(meta.description), trailing: CupertinoSwitch( value: widget.controller.relays.contains(relay), onChanged: (newValue) { @@ -295,7 +391,7 @@ class _RelaySelectSheetState extends State { miuiIsGapless: true, child: Column( children: [ - if (loadStatus == LoadStatus.loading) + if (loadStatus == LoadStatus.loading && availableRelays.isEmpty) Expanded( child: Center( child: PlatformCircularProgressIndicator(),