Skip to content

Commit

Permalink
Implement feature #240 Retrieve more tweets on search.
Browse files Browse the repository at this point in the history
  • Loading branch information
j-fbriere committed Mar 20, 2024
1 parent 2c0be3d commit 5f37eb6
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 62 deletions.
4 changes: 4 additions & 0 deletions fastlane/metadata/android/en-US/changelogs/42.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
* Implement feature #145 Option to use system accent color.
Go to Settings / Theme / select Accent in Theme list.
* Implement feature #240 Retrieve more tweets on search.
By keeping scrolling there are new requests automatically made.
* Implement feature #242 Rearrange subscriptions\pin favorite subscriptions to top.
On the Subscriptions page, simply drag each subscription to the desired position.
4 changes: 4 additions & 0 deletions fastlane/metadata/android/en-US/changelogs/default.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
* Implement feature #145 Option to use system accent color.
Go to Settings / Theme / select Accent in Theme list.
* Implement feature #240 Retrieve more tweets on search.
By keeping scrolling there are new requests automatically made.
* Implement feature #242 Rearrange subscriptions\pin favorite subscriptions to top.
On the Subscriptions page, simply drag each subscription to the desired position.
33 changes: 23 additions & 10 deletions lib/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ class Twitter {
return tweet;
}

static Future<List<UserWithExtra>> searchUsers(String query, {int limit = 25, int? page}) async {
static Future<SearchStatus<UserWithExtra>> searchUsers(String query, {int limit = 25, int? page}) async {
var queryParameters = {
'count': limit.toString(),
'q': query
Expand All @@ -571,18 +571,20 @@ class Twitter {

var response = await _twitterApi.client.get(Uri.https('api.twitter.com', '/1.1/users/search.json', queryParameters));
if (response.body.isEmpty) {
return [];
return SearchStatus(items: []);
}

List result = json.decode(response.body);
if (result.isEmpty) {
return [];
return SearchStatus(items: []);
}

return result.map((e) => UserWithExtra.fromJson(e)).toList();
List<UserWithExtra> users = result.map((e) => UserWithExtra.fromJson(e)).toList();

return SearchStatus(items: users);
}

static Future<List<UserWithExtra>> searchUsersGraphql(String query, {int limit = 25, String? cursor}) async {
static Future<SearchStatus<UserWithExtra>> searchUsersGraphql(String query, {int limit = 25, String? cursor}) async {
var variables = {
"rawQuery": query,
"count": limit.toString(),
Expand All @@ -603,27 +605,31 @@ class Twitter {

var response = await _twitterApi.client.get(uri);
if (response.body.isEmpty) {
return [];
return SearchStatus(items: []);
}

var result = json.decode(response.body);
if (result.isEmpty) {
return [];
return SearchStatus(items: []);
}

List instructions = List.from(result?['data']?['search_by_raw_query']?['search_timeline']?['timeline']?['instructions'] ?? []);
if (instructions.isEmpty) {
return [];
return SearchStatus(items: []);
}
List addEntries = List.from(instructions.firstWhere((e) => e['type'] == 'TimelineAddEntries', orElse: () => null)?['entries'] ?? []);
if (addEntries.isEmpty) {
return [];
return SearchStatus(items: []);
}

return addEntries.where((entry) => entry['entryId']?.startsWith('user-')).where((entry) => entry['content']?['itemContent']?['user_results']?['result']?['legacy'] != null).map((entry) {
List<UserWithExtra> users = addEntries.where((entry) => entry['entryId']?.startsWith('user-')).where((entry) => entry['content']?['itemContent']?['user_results']?['result']?['legacy'] != null).map((entry) {
var res = entry['content']['itemContent']['user_results']['result'];
return UserWithExtra.fromJson({...res['legacy'], 'id_str': res['rest_id'], 'ext_is_blue_verified': res['is_blue_verified']});
}).toList();

String? cursorBottom = addEntries.firstWhereOrNull((entry) => entry['entryId']?.startsWith('cursor-bottom-'))?['content']?['value'];

return SearchStatus(items: users, cursorBottom: cursorBottom);
}

static Future<List<TrendLocation>> getTrendLocations() async {
Expand Down Expand Up @@ -1341,6 +1347,13 @@ class TweetStatus {
TweetStatus({required this.chains, required this.cursorBottom, required this.cursorTop});
}

class SearchStatus<T> {
final List<T> items;
final String? cursorBottom;

SearchStatus({required this.items, this.cursorBottom});
}

class TwitterError {
final String uri;
final int code;
Expand Down
107 changes: 75 additions & 32 deletions lib/search/search.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_triple/flutter_triple.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:squawker/client/client.dart';
import 'package:squawker/client/client_account.dart';
import 'package:squawker/constants.dart';
Expand Down Expand Up @@ -117,11 +118,11 @@ class _SearchScreenState extends State<_SearchScreen> with SingleTickerProviderS
var currentlyFollowed = state.any((element) => element.id == id);
if (!currentlyFollowed) {
return IconButton(
icon: const Icon(Icons.save_rounded),
onPressed: () async {
await subscriptionsModel.toggleSubscribe(
SearchSubscription(id: id, createdAt: DateTime.now()), currentlyFollowed);
});
icon: const Icon(Icons.save_rounded),
onPressed: () async {
await subscriptionsModel.toggleSubscribe(
SearchSubscription(id: id, createdAt: DateTime.now()), currentlyFollowed);
});
}
}

Expand Down Expand Up @@ -150,27 +151,27 @@ class _SearchScreenState extends State<_SearchScreen> with SingleTickerProviderS
MultiProvider(
providers: [
ChangeNotifierProvider<TweetContextState>(
create: (_) => TweetContextState(prefs.get(optionTweetsHideSensitive))),
create: (_) => TweetContextState(prefs.get(optionTweetsHideSensitive))),
ChangeNotifierProvider<VideoContextState>(
create: (_) => VideoContextState(prefs.get(optionMediaDefaultMute))),
create: (_) => VideoContextState(prefs.get(optionMediaDefaultMute))),
],
child: Expanded(
child: TabBarView(controller: _tabController, children: [
TweetSearchResultList<SearchUsersModel, UserWithExtra>(
queryController: _queryController,
store: context.read<SearchUsersModel>(),
searchFunction: (q) => context.read<SearchUsersModel>().searchUsers(q, PrefService.of(context).get(optionEnhancedSearches)),
searchFunction: (q, c) => context.read<SearchUsersModel>().searchUsers(q, PrefService.of(context).get(optionEnhancedSearches), cursor: c),
itemBuilder: (context, user) => UserTile(user: UserSubscription.fromUser(user))),
TweetSearchResultList<SearchTweetsModel, TweetWithCard>(
queryController: _queryController,
store: context.read<SearchTweetsModel>(),
searchFunction: (q) => context.read<SearchTweetsModel>().searchTweets(q, PrefService.of(context).get(optionEnhancedSearches)),
searchFunction: (q, c) => context.read<SearchTweetsModel>().searchTweets(q, PrefService.of(context).get(optionEnhancedSearches), cursor: c),
itemBuilder: (context, item) => TweetTile(tweet: item, clickable: true)),
TweetSearchResultList<SearchTweetsModel, TweetWithCard>(
queryController: _queryController,
store: context.read<SearchTweetsModel>(),
searchFunction: (q) => context.read<SearchTweetsModel>().searchTweets(q, PrefService.of(context).get(optionEnhancedSearches), trending: true),
itemBuilder: (context, item) => TweetTile(tweet: item, clickable: true))
queryController: _queryController,
store: context.read<SearchTweetsModel>(),
searchFunction: (q, c) => context.read<SearchTweetsModel>().searchTweets(q, PrefService.of(context).get(optionEnhancedSearches), trending: true, cursor: c),
itemBuilder: (context, item) => TweetTile(tweet: item, clickable: true))
])),
)
],
Expand All @@ -182,10 +183,10 @@ class _SearchScreenState extends State<_SearchScreen> with SingleTickerProviderS

typedef ItemWidgetBuilder<T> = Widget Function(BuildContext context, T item);

class TweetSearchResultList<S extends Store<List<T>>, T> extends StatefulWidget {
class TweetSearchResultList<S extends Store<SearchStatus<T>>, T> extends StatefulWidget {
final TextEditingController queryController;
final S store;
final Future<void> Function(String query) searchFunction;
final Future<void> Function(String query, String? cursor) searchFunction;
final ItemWidgetBuilder<T> itemBuilder;

const TweetSearchResultList(
Expand All @@ -200,16 +201,23 @@ class TweetSearchResultList<S extends Store<List<T>>, T> extends StatefulWidget
State<TweetSearchResultList<S, T>> createState() => _TweetSearchResultListState<S, T>();
}

class _TweetSearchResultListState<S extends Store<List<T>>, T> extends State<TweetSearchResultList<S, T>> {
class _TweetSearchResultListState<S extends Store<SearchStatus<T>>, T> extends State<TweetSearchResultList<S, T>> {
Timer? _debounce;
String? _previousQuery = '';
String _previousQuery = '';
String? _previousCursor;
late PagingController<String?, T> _pagingController;
late ScrollController _scrollController;
double _lastOffset = 0;
bool _inAppend = false;

@override
void initState() {
super.initState();

_previousQuery = '';
_previousCursor = null;
widget.queryController.addListener(() {
var query = widget.queryController.text;
String query = widget.queryController.text;
if (query == _previousQuery) {
return;
}
Expand All @@ -221,44 +229,79 @@ class _TweetSearchResultListState<S extends Store<List<T>>, T> extends State<Twe

// Debounce the search, so we don't make a request per keystroke
_debounce = Timer(const Duration(milliseconds: 750), () async {
fetchResults();
fetchResults(null);
});
});

fetchResults();
_scrollController = ScrollController();
_pagingController = PagingController(firstPageKey: null);
_pagingController.addPageRequestListener((String? cursor) {
fetchResults(cursor);
});

fetchResults(null);
}

@override
void dispose() {
super.dispose();
_scrollController.dispose();
_pagingController.dispose();
}

void fetchResults() {
void fetchResults(String? cursor) {
if (mounted) {
var query = widget.queryController.text;
String query = widget.queryController.text;
if (query == _previousQuery && cursor == _previousCursor) {
widget.searchFunction('', null);
return;
}
_previousQuery = query;
widget.searchFunction(query);
_previousCursor = cursor;
widget.searchFunction(query, cursor);
}
}

@override
Widget build(BuildContext context) {
return ScopedBuilder<S, List<T>>.transition(
return ScopedBuilder<S, SearchStatus<T>>.transition(
store: widget.store,
onLoading: (_) => const Center(child: CircularProgressIndicator()),
onError: (_, error) => FullPageErrorWidget(
error: error,
stackTrace: null,
prefix: L10n.of(context).unable_to_load_the_search_results,
onRetry: () => fetchResults(),
onRetry: () => fetchResults(_previousCursor),
),
onState: (_, items) {
if (items.isEmpty) {
onState: (_, state) {
if (state.items.isEmpty) {
return Center(child: Text(L10n.of(context).no_results));
}

return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return widget.itemBuilder(context, items[index]);
},
if (_previousQuery.isNotEmpty) {
_inAppend = true;
_pagingController.appendPage(state.items, state.cursorBottom);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo(_lastOffset);
_inAppend = false;
});
}

return PagedListView<String?, T>(
scrollController: _scrollController,
pagingController: _pagingController,
addAutomaticKeepAlives: false,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, elm, index) {
if (!_inAppend) {
_lastOffset = _scrollController.offset;
}
return widget.itemBuilder(context, elm);
}
)
);
},
);
}
}

34 changes: 14 additions & 20 deletions lib/search/search_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,40 @@ import 'package:flutter_triple/flutter_triple.dart';
import 'package:squawker/client/client.dart';
import 'package:squawker/user.dart';

class SearchTweetsModel extends Store<List<TweetWithCard>> {
SearchTweetsModel() : super([]);
class SearchTweetsModel extends Store<SearchStatus<TweetWithCard>> {
SearchTweetsModel() : super(SearchStatus(items: []));

Future<void> searchTweets(String query, bool enhanced, {bool trending = false}) async {
Future<void> searchTweets(String query, bool enhanced, {bool trending = false, String? cursor}) async {
await execute(() async {
if (query.isEmpty) {
return [];
return SearchStatus(items: []);
} else {
if (enhanced) {
return (await Twitter.searchTweetsGraphql(query, true, trending: trending))
.chains
.map((e) => e.tweets)
.expand((element) => element)
.toList();
TweetStatus ts = await Twitter.searchTweetsGraphql(query, true, trending: trending, cursor: cursor);
return SearchStatus(items: ts.chains.map((e) => e.tweets).expand((e) => e).toList(), cursorBottom: ts.cursorBottom);
}
else {
return (await Twitter.searchTweets(query, true))
.chains
.map((e) => e.tweets)
.expand((element) => element)
.toList();
TweetStatus ts = await Twitter.searchTweets(query, true, cursor: cursor, cursorType: cursor != null ? 'cursor_bottom' : null);
return SearchStatus(items: ts.chains.map((e) => e.tweets).expand((e) => e).toList(), cursorBottom: ts.cursorBottom);
}
}
});
}
}

class SearchUsersModel extends Store<List<UserWithExtra>> {
SearchUsersModel() : super([]);
class SearchUsersModel extends Store<SearchStatus<UserWithExtra>> {
SearchUsersModel() : super(SearchStatus(items: []));

Future<void> searchUsers(String query, bool enhanced) async {
Future<void> searchUsers(String query, bool enhanced, {String? cursor}) async {
await execute(() async {
if (query.isEmpty) {
return [];
return SearchStatus(items: []);
} else {
if (enhanced) {
return await Twitter.searchUsersGraphql(query);
return await Twitter.searchUsersGraphql(query, limit: 100, cursor: cursor);
}
else {
return await Twitter.searchUsers(query);
return await Twitter.searchUsers(query, limit: 100);
}
}
});
Expand Down

0 comments on commit 5f37eb6

Please sign in to comment.