diff --git a/fastlane/metadata/android/en-US/changelogs/40.txt b/fastlane/metadata/android/en-US/changelogs/40.txt index 452f6700..97612eb4 100644 --- a/fastlane/metadata/android/en-US/changelogs/40.txt +++ b/fastlane/metadata/android/en-US/changelogs/40.txt @@ -1,4 +1,14 @@ * Fix issue #226 App does not open, showing a black screen. Some db records have unexpected null fields. * Implement feature #218 Share tweet as image. * Implement feature #232 Allow unauthenticated access like with a browser. + What is possible to do with unauthenticated access: + - view specific tweets (but not their threads); + - view a profile; + - view what is trending; + - import subscriptions. + What is not possible to do with unauthenticated access: + - view search results; + - view feed/groups timelines; + - view tweet threads. * Implement feature #203 View profile picture and banner. +* Implement feature #234 Ability to import specific subscriptions. diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt index 452f6700..97612eb4 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -1,4 +1,14 @@ * Fix issue #226 App does not open, showing a black screen. Some db records have unexpected null fields. * Implement feature #218 Share tweet as image. * Implement feature #232 Allow unauthenticated access like with a browser. + What is possible to do with unauthenticated access: + - view specific tweets (but not their threads); + - view a profile; + - view what is trending; + - import subscriptions. + What is not possible to do with unauthenticated access: + - view search results; + - view feed/groups timelines; + - view tweet threads. * Implement feature #203 View profile picture and banner. +* Implement feature #234 Ability to import specific subscriptions. diff --git a/lib/client/client.dart b/lib/client/client.dart index e6a93987..f38ce12d 100644 --- a/lib/client/client.dart +++ b/lib/client/client.dart @@ -16,6 +16,13 @@ import 'package:quiver/iterables.dart'; const Duration _defaultTimeout = Duration(seconds: 30); +class _SquawkerTwitterClientAllowUnauthenticated extends _SquawkerTwitterClient { + @override + Future get(Uri uri, {Map? headers, Duration? timeout}) async { + return getWithRateFetchCtx(uri, headers: headers, timeout: timeout, allowUnauthenticated: true); + } +} + class _SquawkerTwitterClient extends TwitterClient { static final log = Logger('_SquawkerTwitterClient'); @@ -26,10 +33,6 @@ class _SquawkerTwitterClient extends TwitterClient { return getWithRateFetchCtx(uri, headers: headers, timeout: timeout); } - Future getAllowUnauthenticated(Uri uri, {Map? headers, Duration? timeout}) async { - return getWithRateFetchCtx(uri, headers: headers, timeout: timeout, allowUnauthenticated: true); - } - Future getWithRateFetchCtx(Uri uri, {Map? headers, Duration? timeout, RateFetchContext? fetchContext, bool allowUnauthenticated = false}) async { try { if (allowUnauthenticated && !TwitterAccount.hasAccountAvailable()) { @@ -83,6 +86,7 @@ class UnknownProfileUnavailableReason implements Exception { class Twitter { static final TwitterApi _twitterApi = TwitterApi(client: _SquawkerTwitterClient()); + static final TwitterApi _twitterApiAllowUnauthenticated = TwitterApi(client: _SquawkerTwitterClientAllowUnauthenticated()); static final FFCache _cache = FFCache(); @@ -217,7 +221,7 @@ class Twitter { } static Future _getProfile(Uri uri, {bool allowAuthenticated = false}) async { - var response = await (allowAuthenticated ? (_twitterApi.client as _SquawkerTwitterClient).getAllowUnauthenticated(uri) : _twitterApi.client.get(uri)); + var response = await (allowAuthenticated ? _twitterApiAllowUnauthenticated.client.get(uri) : _twitterApi.client.get(uri)); if (response.body.isEmpty) { throw TwitterError(code: 0, message: 'Response is empty', uri: uri.toString()); } @@ -266,9 +270,9 @@ class Twitter { static Future getProfileFollows(String screenName, String type, {int? cursor, int? count = 200}) async { var response = type == 'following' - ? await _twitterApi.userService + ? await _twitterApiAllowUnauthenticated.userService .friendsList(screenName: screenName, cursor: cursor, count: count, skipStatus: true) - : await _twitterApi.userService + : await _twitterApiAllowUnauthenticated.userService .followersList(screenName: screenName, cursor: cursor, count: count, skipStatus: true); return Follows( @@ -341,7 +345,7 @@ class Twitter { 'includePromotedContent': false, 'withVoice': false }; - var response = await (_twitterApi.client as _SquawkerTwitterClient).getAllowUnauthenticated(Uri.https('api.twitter.com', '/graphql/pq4JqttrkAz73WE6s2yUqg/TweetResultByRestId', { + var response = await _twitterApiAllowUnauthenticated.client.get(Uri.https('api.twitter.com', '/graphql/pq4JqttrkAz73WE6s2yUqg/TweetResultByRestId', { 'variables': jsonEncode(variables), 'features': jsonEncode(defaultFeaturesUnauthenticated), })); @@ -632,31 +636,9 @@ class Twitter { return List.from(jsonDecode(result)).map((e) => TrendLocation.fromJson(e)).toList(growable: false); } - static void _addParameter(Map map, String param, dynamic value) { - if (value is List) { - map[param] = value.join(','); - } else if (value != null) { - map[param] = '$value'; - } - } - - // reference: dart_twitter_api::TrendsService.place() - static Future> _placeAllowUnauthenticated({ - required int id, - String? exclude, - TransformResponse> transform = defaultTrendsListTransform - }) async { - final params = {}; - _addParameter(params, 'id', id); - _addParameter(params, 'exclude', exclude); - - return (_twitterApi.client as _SquawkerTwitterClient) - .getAllowUnauthenticated(Uri.https('api.twitter.com', '1.1/trends/place.json', params)).then(transform); - } - static Future> getTrends(int location) async { var result = await _cache.getOrCreateAsJSON('trends.$location', const Duration(minutes: 2), () async { - var trends = await _placeAllowUnauthenticated(id: location); + var trends = await _twitterApiAllowUnauthenticated.trendsService.place(id: location); return jsonEncode(trends.map((e) => e.toJson()).toList()); }); @@ -675,7 +657,7 @@ class Twitter { 'withVoice': true, 'withV2Timeline': true }; - var response = await (_twitterApi.client as _SquawkerTwitterClient).getAllowUnauthenticated(Uri.https('api.twitter.com', '/graphql/WmvfySbQ0FeY1zk4HU_5ow/UserTweets', { + var response = await _twitterApiAllowUnauthenticated.client.get(Uri.https('api.twitter.com', '/graphql/WmvfySbQ0FeY1zk4HU_5ow/UserTweets', { 'variables': jsonEncode(variables), 'features': jsonEncode(defaultFeaturesUnauthenticated) })); @@ -1009,12 +991,39 @@ class Twitter { return (await Future.wait(futures)).expand((element) => element).toList(); } + static Future> getUsersByScreenName(Iterable screenNames) async { + // Split into groups of 100, as the API only supports that many at a time + List>> futures = []; + + var groups = partition(screenNames, 100); + for (var group in groups) { + futures.add(_getUsersPageByScreenName(group)); + } + + return (await Future.wait(futures)).expand((element) => element).toList(); + } + static Future> _getUsersPage(Iterable ids) async { - var response = await _twitterApi.client.get(Uri.https('api.twitter.com', '/1.1/users/lookup.json', { + var response = await _twitterApiAllowUnauthenticated.client.get(Uri.https('api.twitter.com', '/1.1/users/lookup.json', { ...defaultParams, 'user_id': ids.join(','), })); + if (response.body.isEmpty) { + return []; + } + + var result = json.decode(response.body); + + return List.from(result).map((e) => UserWithExtra.fromJson(e)).toList(growable: false); + } + + static Future> _getUsersPageByScreenName(Iterable screenNames) async { + var response = await _twitterApiAllowUnauthenticated.client.get(Uri.https('api.twitter.com', '/1.1/users/lookup.json', { + ...defaultParams, + 'screen_name': screenNames.join(','), + })); + var result = json.decode(response.body); return List.from(result).map((e) => UserWithExtra.fromJson(e)).toList(growable: false); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 35d35e24..6f5c4390 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -199,6 +199,9 @@ class MessageLookup extends MessageLookupByLibrary { "Enhanced requests for searches (but with lower rate limits)"), "enhanced_searches_label": MessageLookupByLibrary.simpleMessage("Enhanced searches"), + "enter_comma_separated_twitter_usernames": + MessageLookupByLibrary.simpleMessage( + "Enter your comma separated Twitter/X usernames"), "enter_your_twitter_username": MessageLookupByLibrary.simpleMessage( "Enter your Twitter/X username"), "error_from_twitter": @@ -455,6 +458,9 @@ class MessageLookup extends MessageLookupByLibrary { "thumbnail_not_available": MessageLookupByLibrary.simpleMessage("Thumbnail not available"), "timed_out": MessageLookupByLibrary.simpleMessage("Timed out"), + "to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": + MessageLookupByLibrary.simpleMessage( + "To import specific subscriptions, enter your comma separated usernames below."), "to_import_subscriptions_from_an_existing_twitter_account_enter_your_username_below": MessageLookupByLibrary.simpleMessage( "To import subscriptions from an existing Twitter/X account, enter your username below."), @@ -550,6 +556,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("User not found"), "username": MessageLookupByLibrary.simpleMessage("Username"), "username_label": MessageLookupByLibrary.simpleMessage("Username:"), + "usernames": MessageLookupByLibrary.simpleMessage("Usernames"), "version": MessageLookupByLibrary.simpleMessage("Version"), "warning_regular_account_unauthenticated_access_description": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/intl/messages_fr.dart b/lib/generated/intl/messages_fr.dart index 5cfaa02c..6267cce0 100644 --- a/lib/generated/intl/messages_fr.dart +++ b/lib/generated/intl/messages_fr.dart @@ -210,6 +210,9 @@ class MessageLookup extends MessageLookupByLibrary { "Requêtes améliorés pour les recherches (mais avec des limites plus basses de fréquence)"), "enhanced_searches_label": MessageLookupByLibrary.simpleMessage("Recherches améliorés"), + "enter_comma_separated_twitter_usernames": + MessageLookupByLibrary.simpleMessage( + "Saisissez la liste d\'utilisateurs séparés par des virgules"), "enter_your_twitter_username": MessageLookupByLibrary.simpleMessage( "Entrer votre nom d\'utilisateur Twitter/X"), "error_from_twitter": @@ -471,6 +474,9 @@ class MessageLookup extends MessageLookupByLibrary { "thumbnail_not_available": MessageLookupByLibrary.simpleMessage("Miniature non disponible"), "timed_out": MessageLookupByLibrary.simpleMessage("Délai expiré"), + "to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": + MessageLookupByLibrary.simpleMessage( + "Pour importer des abonnements spécifiques, saisissez une liste d\'utilisateurs séparés par des virgules ci-dessous."), "to_import_subscriptions_from_an_existing_twitter_account_enter_your_username_below": MessageLookupByLibrary.simpleMessage( "Pour importer des abonnements depuis un compte Twitter/X existant, saisissez votre nom d\'utilisateur ci-dessous."), @@ -566,6 +572,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Utilisateur non trouvé"), "username": MessageLookupByLibrary.simpleMessage("Nom d’utilisateur"), "username_label": MessageLookupByLibrary.simpleMessage("Identifiant :"), + "usernames": MessageLookupByLibrary.simpleMessage("Utilisateurs"), "version": MessageLookupByLibrary.simpleMessage("Version"), "warning_regular_account_unauthenticated_access_description": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index d58ee845..cd20fb02 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2985,6 +2985,38 @@ class L10n { args: [], ); } + + /// `To import specific subscriptions, enter your comma separated usernames below.` + String + get to_import_specific_subscriptions_enter_your_comma_separated_usernames_below { + return Intl.message( + 'To import specific subscriptions, enter your comma separated usernames below.', + name: + 'to_import_specific_subscriptions_enter_your_comma_separated_usernames_below', + desc: '', + args: [], + ); + } + + /// `Enter your comma separated Twitter/X usernames` + String get enter_comma_separated_twitter_usernames { + return Intl.message( + 'Enter your comma separated Twitter/X usernames', + name: 'enter_comma_separated_twitter_usernames', + desc: '', + args: [], + ); + } + + /// `Usernames` + String get usernames { + return Intl.message( + 'Usernames', + name: 'usernames', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 3957de13..a3183ab8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -578,5 +578,11 @@ "proxy_error": "Proxy Error", "@proxy_error": {}, "share_tweet_as_image": "Share tweet as image", - "@share_tweet_as_image": {} + "@share_tweet_as_image": {}, + "to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": "To import specific subscriptions, enter your comma separated usernames below.", + "@to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": {}, + "enter_comma_separated_twitter_usernames": "Enter your comma separated Twitter/X usernames", + "@enter_comma_separated_twitter_usernames": {}, + "usernames": "Usernames", + "@usernames": {} } diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 9cdbeb68..83d281a1 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -608,5 +608,11 @@ "proxy_error": "Erreur Proxy", "@proxy_error": {}, "share_tweet_as_image": "Partager le tweet en image", - "@share_tweet_as_image": {} + "@share_tweet_as_image": {}, + "to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": "Pour importer des abonnements spécifiques, saisissez une liste d'utilisateurs séparés par des virgules ci-dessous.", + "@to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": {}, + "enter_comma_separated_twitter_usernames": "Saisissez la liste d'utilisateurs séparés par des virgules", + "@enter_comma_separated_twitter_usernames": {}, + "usernames": "Utilisateurs", + "@usernames": {} } diff --git a/lib/settings/_account.dart b/lib/settings/_account.dart index b12f41a9..f2b336ee 100644 --- a/lib/settings/_account.dart +++ b/lib/settings/_account.dart @@ -252,7 +252,7 @@ class _AddAccountDialogState extends State { controller: _usernameController, decoration: InputDecoration(contentPadding: EdgeInsets.all(5)), onChanged: (text) { - _username = text; + _username = text.trim(); _checkEnabledSave(); }, ), @@ -287,7 +287,7 @@ class _AddAccountDialogState extends State { ), keyboardType: TextInputType.visiblePassword, onChanged: (text) { - _password = text; + _password = text.trim(); _checkEnabledSave(); }, ), @@ -309,7 +309,7 @@ class _AddAccountDialogState extends State { controller: _nameController, decoration: InputDecoration(contentPadding: EdgeInsets.all(5)), onChanged: (text) { - _name = text; + _name = text.trim(); }, ), ), @@ -328,7 +328,7 @@ class _AddAccountDialogState extends State { controller: _emailController, decoration: InputDecoration(contentPadding: EdgeInsets.all(5)), onChanged: (text) { - _email = text; + _email = text.trim(); }, ), ), @@ -347,7 +347,7 @@ class _AddAccountDialogState extends State { controller: _phoneController, decoration: InputDecoration(contentPadding: EdgeInsets.all(5)), onChanged: (text) { - _phone = text; + _phone = text.trim(); }, ), ), diff --git a/lib/subscriptions/_import.dart b/lib/subscriptions/_import.dart index ec838666..04ef312d 100644 --- a/lib/subscriptions/_import.dart +++ b/lib/subscriptions/_import.dart @@ -4,14 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:squawker/client/client.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/database/entities.dart'; import 'package:squawker/database/repository.dart'; import 'package:squawker/group/group_model.dart'; import 'package:squawker/import_data_model.dart'; import 'package:squawker/subscriptions/users_model.dart'; import 'package:squawker/ui/errors.dart'; +import 'package:squawker/user.dart'; import 'package:squawker/utils/data_service.dart'; -import 'package:squawker/utils/urls.dart'; import 'package:provider/provider.dart'; import 'package:squawker/generated/l10n.dart'; @@ -23,7 +24,8 @@ class SubscriptionImportScreen extends StatefulWidget { } class _SubscriptionImportScreenState extends State { - String? _screenName; + String? _fromScreenName; + String? _specificScreenNames; StreamController? _streamController; Future importSubscriptions() async { @@ -32,8 +34,7 @@ class _SubscriptionImportScreenState extends State { }); try { - var screenName = _screenName; - if (screenName == null || screenName.isEmpty) { + if ((_fromScreenName?.trim().isEmpty ?? true) && (_specificScreenNames?.trim().isEmpty ?? true)) { return; } @@ -48,19 +49,64 @@ class _SubscriptionImportScreenState extends State { var createdAt = DateTime.now(); - while (true) { - var response = await Twitter.getProfileFollows( - screenName, - 'following', - cursor: cursor, - ); + if (_fromScreenName?.trim().isNotEmpty ?? false) { + while (true) { + var response = await Twitter.getProfileFollows( + _fromScreenName!, + 'following', + cursor: cursor, + ); - cursor = response.cursorBottom; - total = total + response.users.length; + cursor = response.cursorBottom; + total = total + response.users.length; - await importModel.importData({ - tableSubscription: [ - ...response.users.map((e) => UserSubscription( + if (response.users.isNotEmpty) { + await importModel.importData({ + tableSubscription: [ + ...response.users.map((e) => UserSubscription( + id: e.idStr!, + name: e.name!, + profileImageUrlHttps: e.profileImageUrlHttps, + screenName: e.screenName!, + verified: e.verified ?? false, + inFeed: true, + createdAt: createdAt)) + ] + }); + } + else { + break; + } + + _streamController?.add(total); + + if (cursor == 0 || cursor == -1) { + break; + } + } + } + + if (_specificScreenNames?.trim().isNotEmpty ?? false) { + List users = []; + + if (TwitterAccount.hasAccountAvailable()) { + users = await Twitter.getUsersByScreenName(_specificScreenNames!.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty)); + } + else { + for (String screenName in _specificScreenNames!.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty)) { + try { + users.add((await Twitter.getProfileByScreenName(screenName)).user); + } + catch (err, _) { + _streamController?.addError(err, _); + } + } + } + + if (users.isNotEmpty) { + await importModel.importData({ + tableSubscription: [ + ...users.map((e) => UserSubscription( id: e.idStr!, name: e.name!, profileImageUrlHttps: e.profileImageUrlHttps, @@ -68,13 +114,10 @@ class _SubscriptionImportScreenState extends State { verified: e.verified ?? false, inFeed: true, createdAt: createdAt)) - ] - }); - - _streamController?.add(total); + ] + }); - if (cursor == 0 || cursor == -1) { - break; + _streamController?.add(users.length); } } @@ -110,44 +153,43 @@ class _SubscriptionImportScreenState extends State { ), Padding( padding: const EdgeInsets.only(bottom: 16), - child: Text( - L10n.of(context) - .please_note_that_the_method_fritter_uses_to_import_subscriptions_is_heavily_rate_limited_by_twitter_so_this_may_fail_if_you_have_a_lot_of_followed_accounts, + child: TextFormField( + decoration: InputDecoration( + border: const UnderlineInputBorder(), + hintText: L10n.of(context).enter_your_twitter_username, + hintStyle: TextStyle(fontSize: Theme.of(context).textTheme.labelSmall!.fontSize), + prefixText: '@', + labelText: L10n.of(context).username, + ), + maxLength: 15, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^[a-zA-Z0-9_]+'))], + onChanged: (value) { + setState(() { + _fromScreenName = value; + }); + }, ), ), Padding( padding: const EdgeInsets.only(bottom: 16), - child: Text.rich(TextSpan(children: [ - TextSpan(text: '${L10n.of(context).if_you_have_any_feedback_on_this_feature_please_leave_it_on} '), - WidgetSpan( - child: InkWell( - onTap: () => openUri('https://github.com/jonjomckay/fritter/issues/143'), - child: Text(L10n.of(context).the_github_issue, - style: const TextStyle( - color: Colors.blue, - )), - )), - TextSpan( - text: - '. ${L10n.of(context).selecting_individual_accounts_to_import_and_assigning_groups_are_both_planned_for_the_future_already}', - ) - ])), + child: Text( + L10n.of(context).to_import_specific_subscriptions_enter_your_comma_separated_usernames_below, + ), ), Padding( padding: const EdgeInsets.only(bottom: 16), child: TextFormField( decoration: InputDecoration( border: const UnderlineInputBorder(), - hintText: L10n.of(context).enter_your_twitter_username, - helperText: L10n.of(context).your_profile_must_be_public_otherwise_the_import_will_not_work, - prefixText: '@', - labelText: L10n.of(context).username, + hintText: L10n.of(context).enter_comma_separated_twitter_usernames, + hintStyle: TextStyle(fontSize: Theme.of(context).textTheme.labelSmall!.fontSize), + labelText: L10n.of(context).usernames, ), - maxLength: 15, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^[a-zA-Z0-9_]+'))], + maxLength: 100, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^[a-zA-Z0-9_,]+'))], onChanged: (value) { setState(() { - _screenName = value; + _specificScreenNames = value; }); }, ), diff --git a/lib/subscriptions/users_model.dart b/lib/subscriptions/users_model.dart index eea0ab1a..1157fc47 100644 --- a/lib/subscriptions/users_model.dart +++ b/lib/subscriptions/users_model.dart @@ -1,4 +1,5 @@ import 'package:flutter_triple/flutter_triple.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/client/client.dart'; import 'package:squawker/constants.dart'; import 'package:squawker/database/entities.dart'; @@ -52,6 +53,9 @@ class SubscriptionsModel extends Store> { log.info('Refreshing subscription data'); await execute(() async { + if (!TwitterAccount.hasAccountAvailable()) { + return state; + } var database = await Repository.writable(); var ids = (await database.query(tableSubscription, columns: ['id'])).map((e) => e['id'] as String).toList(); diff --git a/lib/user.dart b/lib/user.dart index 76ba2c40..d80aca7d 100644 --- a/lib/user.dart +++ b/lib/user.dart @@ -193,14 +193,14 @@ class FollowButton extends StatelessWidget { break; case 'toggle_subscribe': GlobalKey? sgfKey = DataService().map['feed_key__1']; - if (sgfKey != null) { + if (sgfKey != null && sgfKey.currentState != null) { await sgfKey.currentState!.updateOffset(); } await model.toggleSubscribe(user, followed); break; case 'toggle_feed': GlobalKey? sgfKey = DataService().map['feed_key__1']; - if (sgfKey != null) { + if (sgfKey != null && sgfKey.currentState != null) { await sgfKey.currentState!.updateOffset(); } await model.toggleFeed(user, inFeed);