mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 13:33:32 +00:00
Merge branch 'main' of gitlab.com:mysocialportal/relatica
This commit is contained in:
commit
bbccbcfbf6
22 changed files with 1139 additions and 189 deletions
|
@ -18,12 +18,19 @@
|
|||
* Toggle like status on first level of comments no longer generates error
|
||||
* Can properly load private images if the access control list is set correctly on the server
|
||||
* Proper paging of notifications on refresh
|
||||
* Drawer stability issues on Contacts Screen
|
||||
* Eliminated excessive cropping of top of Contacts Screen
|
||||
* Can go to account profiles of accounts never seen before from the Interactions Screen (list of which accounts,
|
||||
liked, reshared, etc)
|
||||
* New Features
|
||||
* Post/Comment Editing
|
||||
* Being able to set the posts as private and a single group it is visible to (or "Followers", the default)
|
||||
* Link Previews
|
||||
* Use actual follow requests system not the "follow requests" in notifications if account is on server running
|
||||
Friendica 2023.03 or later
|
||||
* Server side search of: hashtags, statuses, and accounts
|
||||
* Direct loading of status or account URL within the app so it renders within the Friendica network to allow for
|
||||
interactions, commenting on, making follow requests of accounts, etc.
|
||||
|
||||
## Version 0.2.0 (beta), 15 March 2023
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import 'package:provider/provider.dart';
|
|||
import '../routes.dart';
|
||||
import '../services/notifications_manager.dart';
|
||||
import '../utils/active_profile_selector.dart';
|
||||
import '../utils/snackbar_builder.dart';
|
||||
|
||||
enum NavBarButtons {
|
||||
timelines,
|
||||
|
@ -51,7 +50,7 @@ class AppBottomNavBar extends StatelessWidget {
|
|||
context.pushNamed(ScreenPaths.contacts);
|
||||
break;
|
||||
case NavBarButtons.search:
|
||||
buildSnackbar(context, 'Search screen coming soon...');
|
||||
context.pushNamed(ScreenPaths.search);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
|
197
lib/controls/search_result_status_control.dart
Normal file
197
lib/controls/search_result_status_control.dart
Normal file
|
@ -0,0 +1,197 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../utils/clipboard_utils.dart';
|
||||
import '../utils/url_opening_utils.dart';
|
||||
import 'media_attachment_viewer_control.dart';
|
||||
import 'padding.dart';
|
||||
import 'timeline/link_preview_control.dart';
|
||||
import 'timeline/status_header_control.dart';
|
||||
|
||||
class SearchResultStatusControl extends StatefulWidget {
|
||||
static final _logger = Logger('$SearchResultStatusControl');
|
||||
final TimelineEntry status;
|
||||
|
||||
final Future Function() goToPostFunction;
|
||||
|
||||
const SearchResultStatusControl(this.status, this.goToPostFunction,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
State<SearchResultStatusControl> createState() =>
|
||||
_SearchResultStatusControlState();
|
||||
}
|
||||
|
||||
class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
|
||||
var showContent = false;
|
||||
|
||||
TimelineEntry get status => widget.status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
showContent = widget.status.spoilerText.isEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SearchResultStatusControl._logger
|
||||
.finest('Building ${widget.status.toShortString()}');
|
||||
const otherPadding = 8.0;
|
||||
final body = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
border: Border.all(width: 0.5),
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).dividerColor,
|
||||
blurRadius: 2,
|
||||
offset: Offset(4, 4),
|
||||
spreadRadius: 0.1,
|
||||
blurStyle: BlurStyle.normal,
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatusHeaderControl(
|
||||
entry: widget.status,
|
||||
showIsCommentText: true,
|
||||
),
|
||||
),
|
||||
buildMenuControl(context),
|
||||
],
|
||||
),
|
||||
const VerticalPadding(
|
||||
height: 5,
|
||||
),
|
||||
if (status.spoilerText.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
showContent = !showContent;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
'Content Summary: ${status.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')),
|
||||
if (showContent) ...[
|
||||
buildBody(context),
|
||||
const VerticalPadding(
|
||||
height: 5,
|
||||
),
|
||||
if (status.linkPreviewData != null)
|
||||
LinkPreviewControl(preview: status.linkPreviewData!),
|
||||
buildMediaBar(context),
|
||||
],
|
||||
const VerticalPadding(
|
||||
height: 5,
|
||||
),
|
||||
const VerticalPadding(
|
||||
height: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: otherPadding,
|
||||
right: otherPadding,
|
||||
top: otherPadding,
|
||||
bottom: otherPadding,
|
||||
),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
return HtmlWidget(
|
||||
widget.status.body,
|
||||
onTapUrl: (url) async {
|
||||
return await openUrlStringInSystembrowser(context, url, 'link');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildMediaBar(BuildContext context) {
|
||||
final items = widget.status.mediaAttachments;
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return SizedBox(
|
||||
height: 250.0,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return MediaAttachmentViewerControl(
|
||||
attachments: items,
|
||||
index: index,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return HorizontalPadding();
|
||||
},
|
||||
itemCount: items.length));
|
||||
}
|
||||
|
||||
Widget buildMenuControl(BuildContext context) {
|
||||
const goToPost = 'Open Post';
|
||||
const copyText = 'Copy Post Text';
|
||||
const copyUrl = 'Copy URL';
|
||||
const openExternal = 'Open In Browser';
|
||||
final options = [
|
||||
goToPost,
|
||||
copyText,
|
||||
openExternal,
|
||||
copyUrl,
|
||||
];
|
||||
|
||||
return PopupMenuButton<String>(onSelected: (menuOption) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (menuOption) {
|
||||
case goToPost:
|
||||
await widget.goToPostFunction();
|
||||
break;
|
||||
case openExternal:
|
||||
await openUrlStringInSystembrowser(
|
||||
context,
|
||||
widget.status.externalLink,
|
||||
'Status',
|
||||
);
|
||||
break;
|
||||
case copyUrl:
|
||||
await copyToClipboard(
|
||||
context: context,
|
||||
text: widget.status.externalLink,
|
||||
message: 'Status link copied to clipboard',
|
||||
);
|
||||
break;
|
||||
case copyText:
|
||||
await copyToClipboard(
|
||||
context: context,
|
||||
text: widget.status.body,
|
||||
message: 'Status text copied to clipboard',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
//do nothing
|
||||
}
|
||||
}, itemBuilder: (context) {
|
||||
return options
|
||||
.map((o) => PopupMenuItem(value: o, child: Text(o)))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../globals.dart';
|
||||
import '../routes.dart';
|
||||
|
@ -7,6 +8,15 @@ import '../services/auth_service.dart';
|
|||
import 'login_aware_cached_network_image.dart';
|
||||
|
||||
class StandardAppDrawer extends StatelessWidget {
|
||||
final bool skipPopDismiss;
|
||||
|
||||
static final _logger = Logger('$StandardAppDrawer');
|
||||
|
||||
const StandardAppDrawer({
|
||||
super.key,
|
||||
this.skipPopDismiss = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
|
@ -18,8 +28,16 @@ class StandardAppDrawer extends StatelessWidget {
|
|||
(p) => ListTile(
|
||||
onTap: () async {
|
||||
await getIt<AccountsService>().setActiveProfile(p);
|
||||
if (context.mounted && context.canPop()) {
|
||||
context.pop();
|
||||
if (!skipPopDismiss &&
|
||||
context.mounted &&
|
||||
context.canPop()) {
|
||||
try {
|
||||
context.pop();
|
||||
} catch (e) {
|
||||
context.go(ScreenPaths.timelines);
|
||||
_logger.severe(
|
||||
'Error trying to close the drawer, going home: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
leading: CircleAvatar(
|
||||
|
|
|
@ -16,10 +16,12 @@ import '../padding.dart';
|
|||
class StatusHeaderControl extends StatelessWidget {
|
||||
static final _logger = Logger('$StatusHeaderControl');
|
||||
final TimelineEntry entry;
|
||||
final bool showIsCommentText;
|
||||
|
||||
const StatusHeaderControl({
|
||||
super.key,
|
||||
required this.entry,
|
||||
this.showIsCommentText = false,
|
||||
});
|
||||
|
||||
void goToProfile(BuildContext context, String id) {
|
||||
|
@ -97,6 +99,11 @@ class StatusHeaderControl extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
],
|
||||
if (showIsCommentText && entry.parentId.isNotEmpty)
|
||||
Text(
|
||||
' ...made a comment:',
|
||||
style: Theme.of(context).textTheme.bodyText1,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
|
|
|
@ -27,6 +27,7 @@ class TimelinePanel extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.finer('Build');
|
||||
final manager = context
|
||||
.watch<ActiveProfileSelector<TimelineManager>>()
|
||||
.activeEntry
|
||||
|
|
|
@ -16,6 +16,7 @@ import 'services/connections_manager.dart';
|
|||
import 'services/direct_message_service.dart';
|
||||
import 'services/entry_manager_service.dart';
|
||||
import 'services/feature_version_checker.dart';
|
||||
import 'services/fediverse_server_validator.dart';
|
||||
import 'services/follow_requests_manager.dart';
|
||||
import 'services/gallery_service.dart';
|
||||
import 'services/hashtag_service.dart';
|
||||
|
@ -36,6 +37,8 @@ Future<void> dependencyInjectionInitialization() async {
|
|||
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());
|
||||
getIt.registerSingleton<HashtagService>(HashtagService());
|
||||
getIt.registerSingleton<NetworkStatusService>(NetworkStatusService());
|
||||
getIt.registerSingleton<FediverseServiceValidator>(
|
||||
FediverseServiceValidator());
|
||||
getIt.registerSingleton<FriendicaVersionChecker>(
|
||||
const FriendicaVersionChecker());
|
||||
|
||||
|
@ -52,16 +55,19 @@ Future<void> dependencyInjectionInitialization() async {
|
|||
final secretsService = SecretsService();
|
||||
final serviceInit = await secretsService.initialize();
|
||||
|
||||
final authService = AccountsService(secretsService);
|
||||
final accountsService = AccountsService(secretsService);
|
||||
if (serviceInit.isFailure) {
|
||||
_logger.severe('Error initializing credentials');
|
||||
} else {
|
||||
await authService.initialize();
|
||||
await accountsService.initialize();
|
||||
}
|
||||
getIt.registerSingleton<AccountsService>(authService);
|
||||
getIt.registerSingleton<AccountsService>(accountsService);
|
||||
getIt<ActiveProfileSelector<IConnectionsRepo>>().subscribeToProfileSwaps();
|
||||
getIt<ActiveProfileSelector<InstanceInfo>>().subscribeToProfileSwaps();
|
||||
|
||||
getIt.registerSingleton<ActiveProfileSelector<IGroupsRepo>>(
|
||||
ActiveProfileSelector((p) => MemoryGroupsRepo()));
|
||||
ActiveProfileSelector((p) => MemoryGroupsRepo())
|
||||
..subscribeToProfileSwaps());
|
||||
|
||||
getIt.registerSingleton<ActiveProfileSelector<ConnectionsManager>>(
|
||||
ActiveProfileSelector(
|
||||
|
@ -72,24 +78,31 @@ Future<void> dependencyInjectionInitialization() async {
|
|||
));
|
||||
|
||||
getIt.registerSingleton<ActiveProfileSelector<GalleryService>>(
|
||||
ActiveProfileSelector((p) => GalleryService()));
|
||||
ActiveProfileSelector((p) => GalleryService())
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<EntryManagerService>>(
|
||||
ActiveProfileSelector((p) => EntryManagerService()));
|
||||
ActiveProfileSelector((p) => EntryManagerService())
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<TimelineManager>>(
|
||||
ActiveProfileSelector((p) => TimelineManager(
|
||||
getIt<ActiveProfileSelector<IGroupsRepo>>().getForProfile(p).value,
|
||||
getIt<ActiveProfileSelector<EntryManagerService>>()
|
||||
.getForProfile(p)
|
||||
.value,
|
||||
)));
|
||||
))
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<NotificationsManager>>(
|
||||
ActiveProfileSelector((_) => NotificationsManager()));
|
||||
ActiveProfileSelector((_) => NotificationsManager())
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<FollowRequestsManager>>(
|
||||
ActiveProfileSelector((_) => FollowRequestsManager()));
|
||||
ActiveProfileSelector((_) => FollowRequestsManager())
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<DirectMessageService>>(
|
||||
ActiveProfileSelector((p) => DirectMessageService()));
|
||||
ActiveProfileSelector((p) => DirectMessageService())
|
||||
..subscribeToProfileSwaps());
|
||||
getIt.registerSingleton<ActiveProfileSelector<InteractionsManager>>(
|
||||
ActiveProfileSelector((p) => InteractionsManager()));
|
||||
ActiveProfileSelector((p) => InteractionsManager())
|
||||
..subscribeToProfileSwaps());
|
||||
|
||||
getIt.registerLazySingleton<MediaUploadAttachmentHelper>(
|
||||
() => MediaUploadAttachmentHelper());
|
||||
|
|
|
@ -19,6 +19,8 @@ import '../models/group_data.dart';
|
|||
import '../models/image_entry.dart';
|
||||
import '../models/instance_info.dart';
|
||||
import '../models/media_attachment_uploads/image_types_enum.dart';
|
||||
import '../models/search_results.dart';
|
||||
import '../models/search_types.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/user_notification.dart';
|
||||
import '../models/visibility.dart';
|
||||
|
@ -31,9 +33,12 @@ import '../serializers/mastodon/follow_request_mastodon_extensions.dart';
|
|||
import '../serializers/mastodon/group_data_mastodon_extensions.dart';
|
||||
import '../serializers/mastodon/instance_info_mastodon_extensions.dart';
|
||||
import '../serializers/mastodon/notification_mastodon_extension.dart';
|
||||
import '../serializers/mastodon/search_result_mastodon_extensions.dart';
|
||||
import '../serializers/mastodon/timeline_entry_mastodon_extensions.dart';
|
||||
import '../serializers/mastodon/visibility_mastodon_extensions.dart';
|
||||
import '../services/fediverse_server_validator.dart';
|
||||
import '../services/network_status_service.dart';
|
||||
import '../utils/network_utils.dart';
|
||||
import 'paging_data.dart';
|
||||
|
||||
class DirectMessagingClient extends FriendicaClient {
|
||||
|
@ -65,8 +70,8 @@ class DirectMessagingClient extends FriendicaClient {
|
|||
final id = message.id;
|
||||
final url = Uri.parse(
|
||||
'https://$serverName/api/friendica/direct_messages_setseen?id=$id');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return message.copy(seen: true);
|
||||
});
|
||||
_networkStatusService.finishDirectMessageUpdateStatus();
|
||||
|
@ -85,7 +90,7 @@ class DirectMessagingClient extends FriendicaClient {
|
|||
'text': text,
|
||||
if (messageIdRepliedTo != null) 'replyto': messageIdRepliedTo,
|
||||
};
|
||||
final result = await _postUrl(url, body)
|
||||
final result = await postUrl(url, body, headers: _headers)
|
||||
.andThenAsync<DirectMessage, ExecError>((jsonString) async {
|
||||
final json = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
if (json.containsKey('error')) {
|
||||
|
@ -187,7 +192,8 @@ class GroupsClient extends FriendicaClient {
|
|||
final requestData = {
|
||||
'account_ids': [connection.id]
|
||||
};
|
||||
return (await _postUrl(request, requestData)).mapValue((_) => true);
|
||||
return (await postUrl(request, requestData, headers: _headers))
|
||||
.mapValue((_) => true);
|
||||
}
|
||||
|
||||
FutureResult<bool, ExecError> removeConnectionFromGroup(
|
||||
|
@ -200,7 +206,8 @@ class GroupsClient extends FriendicaClient {
|
|||
final requestData = {
|
||||
'account_ids': [connection.id]
|
||||
};
|
||||
return (await _deleteUrl(request, requestData)).mapValue((_) => true);
|
||||
return (await deleteUrl(request, requestData, headers: _headers))
|
||||
.mapValue((_) => true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -251,7 +258,7 @@ class InteractionsClient extends FriendicaClient {
|
|||
String id, bool status) async {
|
||||
final action = status ? 'favourite' : 'unfavourite';
|
||||
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/$action');
|
||||
final result = await _postUrl(url, {});
|
||||
final result = await postUrl(url, {}, headers: _headers);
|
||||
if (result.isFailure) {
|
||||
return result.errorCast();
|
||||
}
|
||||
|
@ -326,7 +333,7 @@ class NotificationsClient extends FriendicaClient {
|
|||
final url = 'https://$serverName/api/v1/notifications/clear';
|
||||
final request = Uri.parse(url);
|
||||
_logger.finest(() => 'Clearing unread notification');
|
||||
final response = await _postUrl(request, {});
|
||||
final response = await postUrl(request, {}, headers: _headers);
|
||||
return response.mapValue((value) => true);
|
||||
}
|
||||
|
||||
|
@ -336,7 +343,7 @@ class NotificationsClient extends FriendicaClient {
|
|||
'https://$serverName/api/v1/notifications/${notification.id}/dismiss';
|
||||
final request = Uri.parse(url);
|
||||
_logger.finest(() => 'Clearing unread notification for $notification');
|
||||
final response = await _postUrl(request, {});
|
||||
final response = await postUrl(request, {}, headers: _headers);
|
||||
return response.mapValue((value) => true);
|
||||
}
|
||||
}
|
||||
|
@ -455,8 +462,8 @@ class RelationshipsClient extends FriendicaClient {
|
|||
final id = connection.id;
|
||||
final url =
|
||||
Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
|
@ -469,8 +476,8 @@ class RelationshipsClient extends FriendicaClient {
|
|||
final id = connection.id;
|
||||
final url =
|
||||
Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
|
@ -483,8 +490,8 @@ class RelationshipsClient extends FriendicaClient {
|
|||
final id = connection.id;
|
||||
final url =
|
||||
Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
|
@ -496,8 +503,8 @@ class RelationshipsClient extends FriendicaClient {
|
|||
Connection connection) async {
|
||||
final id = connection.id;
|
||||
final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
|
@ -509,8 +516,8 @@ class RelationshipsClient extends FriendicaClient {
|
|||
Connection connection) async {
|
||||
final id = connection.id;
|
||||
final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow');
|
||||
final result =
|
||||
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async {
|
||||
final result = await postUrl(url, {}, headers: _headers)
|
||||
.andThenSuccessAsync((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
|
@ -680,7 +687,7 @@ class StatusesClient extends FriendicaClient {
|
|||
'title': '',
|
||||
},
|
||||
};
|
||||
final result = await _postUrl(url, body);
|
||||
final result = await postUrl(url, body, headers: _headers);
|
||||
if (result.isFailure) {
|
||||
return result.errorCast();
|
||||
}
|
||||
|
@ -711,7 +718,7 @@ class StatusesClient extends FriendicaClient {
|
|||
'title': '',
|
||||
},
|
||||
};
|
||||
final result = await _putUrl(url, body);
|
||||
final result = await putUrl(url, body, headers: _headers);
|
||||
if (result.isFailure) {
|
||||
return result.errorCast();
|
||||
}
|
||||
|
@ -729,7 +736,7 @@ class StatusesClient extends FriendicaClient {
|
|||
FutureResult<TimelineEntry, ExecError> resharePost(String id) async {
|
||||
_logger.finest(() => 'Reshare post $id');
|
||||
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog');
|
||||
final result = await _postUrl(url, {});
|
||||
final result = await postUrl(url, {}, headers: _headers);
|
||||
if (result.isFailure) {
|
||||
return result.errorCast();
|
||||
}
|
||||
|
@ -747,7 +754,7 @@ class StatusesClient extends FriendicaClient {
|
|||
FutureResult<TimelineEntry, ExecError> unResharePost(String id) async {
|
||||
_logger.finest(() => 'Reshare post $id');
|
||||
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/unreblog');
|
||||
final result = await _postUrl(url, {});
|
||||
final result = await postUrl(url, {}, headers: _headers);
|
||||
if (result.isFailure) {
|
||||
return result.errorCast();
|
||||
}
|
||||
|
@ -766,7 +773,50 @@ class StatusesClient extends FriendicaClient {
|
|||
_logger.finest(() => 'Deleting post/comment $id');
|
||||
final url = 'https://$serverName/api/v1/statuses/$id';
|
||||
final request = Uri.parse(url);
|
||||
return (await _deleteUrl(request, {})).mapValue((_) => true);
|
||||
return (await deleteUrl(
|
||||
request,
|
||||
{},
|
||||
headers: _headers,
|
||||
))
|
||||
.mapValue((_) => true);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchClient extends FriendicaClient {
|
||||
static final _logger = Logger('$StatusesClient');
|
||||
|
||||
SearchClient(super.credentials) : super();
|
||||
|
||||
FutureResult<PagedResponse<SearchResults>, ExecError> search(
|
||||
SearchTypes type, String searchTerm, PagingData page) async {
|
||||
_logger.finest(() => 'Searching $type for term: $searchTerm');
|
||||
|
||||
if (type == SearchTypes.directLink) {
|
||||
final isFediverseResult = await getIt<FediverseServiceValidator>()
|
||||
.checkIfFediverseLink(searchTerm);
|
||||
if (isFediverseResult.isFailure) {
|
||||
return isFediverseResult.errorCast();
|
||||
}
|
||||
|
||||
if (!isFediverseResult.value) {
|
||||
return buildErrorResult(
|
||||
type: ErrorType.parsingError,
|
||||
message: 'URL appears to not be to a fediverse server: $searchTerm',
|
||||
);
|
||||
}
|
||||
}
|
||||
_networkStatusService.startSearchLoading();
|
||||
final url =
|
||||
'https://$serverName/api/v1/search?${page.toQueryParameters()}&${type.toQueryParameters()}&q=$searchTerm';
|
||||
final result = await _getApiPagedRequest(
|
||||
Uri.parse(url),
|
||||
);
|
||||
|
||||
_networkStatusService.finishSearchLoaing();
|
||||
return result
|
||||
.andThenSuccess((response) => response
|
||||
.map((json) => SearchResultMastodonExtensions.fromJson(json)))
|
||||
.execErrorCast();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -847,8 +897,6 @@ class TimelineClient extends FriendicaClient {
|
|||
}
|
||||
|
||||
abstract class FriendicaClient {
|
||||
static final _logger = Logger('$FriendicaClient');
|
||||
|
||||
final Profile _profile;
|
||||
|
||||
late final NetworkStatusService _networkStatusService;
|
||||
|
@ -861,112 +909,31 @@ abstract class FriendicaClient {
|
|||
_networkStatusService = getIt<NetworkStatusService>();
|
||||
}
|
||||
|
||||
FutureResult<PagedResponse<String>, ExecError> _getUrl(Uri url) async {
|
||||
_logger.finer('GET: $url');
|
||||
try {
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: _header,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return PagedResponse.fromLinkHeader(
|
||||
response.headers['link'],
|
||||
utf8.decode(response.bodyBytes),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> _postUrl(
|
||||
Uri url, Map<String, dynamic> body) async {
|
||||
_logger.finer('POST: $url \n Body: $body');
|
||||
try {
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: _header,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> _putUrl(
|
||||
Uri url, Map<String, dynamic> body) async {
|
||||
_logger.finer('PUT: $url \n Body: $body');
|
||||
try {
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: _header,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> _deleteUrl(
|
||||
Uri url, Map<String, dynamic> body) async {
|
||||
_logger.finer('DELETE: $url');
|
||||
try {
|
||||
final response = await http.delete(
|
||||
url,
|
||||
headers: _header,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<PagedResponse<List<dynamic>>, ExecError> _getApiListRequest(
|
||||
Uri url) async {
|
||||
return (await _getUrl(url).andThenSuccessAsync(
|
||||
return (await getUrl(url, headers: _headers).andThenSuccessAsync(
|
||||
(response) async =>
|
||||
response.map((data) => jsonDecode(data) as List<dynamic>),
|
||||
))
|
||||
.mapError((error) => error as ExecError);
|
||||
}
|
||||
|
||||
FutureResult<PagedResponse<dynamic>, ExecError> _getApiPagedRequest(
|
||||
Uri url) async {
|
||||
return (await getUrl(url, headers: _headers).andThenSuccessAsync(
|
||||
(response) async => response.map((data) => jsonDecode(data)),
|
||||
))
|
||||
.mapError((error) => error as ExecError);
|
||||
}
|
||||
|
||||
FutureResult<dynamic, ExecError> _getApiRequest(Uri url) async {
|
||||
return (await _getUrl(url).andThenSuccessAsync(
|
||||
return (await getUrl(url, headers: _headers).andThenSuccessAsync(
|
||||
(response) async => jsonDecode(response.data),
|
||||
))
|
||||
.execErrorCastAsync();
|
||||
}
|
||||
|
||||
Map<String, String> get _header => {
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': _profile.credentials.authHeaderValue,
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
if (usePhpDebugging) 'Cookie': 'XDEBUG_SESSION=PHPSTORM;path=/',
|
||||
|
|
35
lib/models/search_results.dart
Normal file
35
lib/models/search_results.dart
Normal file
|
@ -0,0 +1,35 @@
|
|||
import 'connection.dart';
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
class SearchResults {
|
||||
final List<Connection> accounts;
|
||||
|
||||
final List<TimelineEntry> statuses;
|
||||
|
||||
final List<String> hashtags;
|
||||
|
||||
const SearchResults({
|
||||
required this.accounts,
|
||||
required this.statuses,
|
||||
required this.hashtags,
|
||||
});
|
||||
|
||||
factory SearchResults.empty() => const SearchResults(
|
||||
accounts: [],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
);
|
||||
|
||||
SearchResults merge(SearchResults newResults) => SearchResults(
|
||||
accounts: [...accounts, ...newResults.accounts],
|
||||
statuses: [...statuses, ...newResults.statuses],
|
||||
hashtags: [...hashtags, ...newResults.hashtags],
|
||||
);
|
||||
|
||||
bool get isEmpty => accounts.isEmpty && statuses.isEmpty && hashtags.isEmpty;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchResults{#accounts: ${accounts.length}, #statuses: ${statuses.length}, #hashtags: ${hashtags.length}}';
|
||||
}
|
||||
}
|
33
lib/models/search_types.dart
Normal file
33
lib/models/search_types.dart
Normal file
|
@ -0,0 +1,33 @@
|
|||
enum SearchTypes {
|
||||
account,
|
||||
statusesText,
|
||||
directLink,
|
||||
hashTag,
|
||||
;
|
||||
|
||||
String toLabel() {
|
||||
switch (this) {
|
||||
case SearchTypes.hashTag:
|
||||
return 'Hashtag';
|
||||
case SearchTypes.account:
|
||||
return 'Account';
|
||||
case SearchTypes.statusesText:
|
||||
return 'Statuses Text';
|
||||
case SearchTypes.directLink:
|
||||
return 'Direct Link';
|
||||
}
|
||||
}
|
||||
|
||||
String toQueryParameters() {
|
||||
switch (this) {
|
||||
case SearchTypes.hashTag:
|
||||
return 'type=hashtags';
|
||||
case SearchTypes.account:
|
||||
return 'type=accounts';
|
||||
case SearchTypes.statusesText:
|
||||
return 'type=statuses';
|
||||
case SearchTypes.directLink:
|
||||
return 'resolve=true';
|
||||
}
|
||||
}
|
||||
}
|
15
lib/models/server_data.dart
Normal file
15
lib/models/server_data.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
class ServerData {
|
||||
final String domainName;
|
||||
final bool isFediverse;
|
||||
final String softwareName;
|
||||
final String softwareVersion;
|
||||
final List<String> protocols;
|
||||
|
||||
ServerData(
|
||||
{required this.domainName,
|
||||
required this.isFediverse,
|
||||
this.softwareName = '',
|
||||
this.softwareVersion = '',
|
||||
List<String>? protocols})
|
||||
: protocols = protocols ?? <String>[];
|
||||
}
|
|
@ -14,6 +14,7 @@ import 'screens/message_threads_browser_screen.dart';
|
|||
import 'screens/messages_new_thread.dart';
|
||||
import 'screens/notifications_screen.dart';
|
||||
import 'screens/post_screen.dart';
|
||||
import 'screens/search_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'screens/sign_in.dart';
|
||||
import 'screens/splash.dart';
|
||||
|
@ -38,6 +39,7 @@ class ScreenPaths {
|
|||
static String userPosts = '/user_posts';
|
||||
static String likes = '/likes';
|
||||
static String reshares = '/reshares';
|
||||
static String search = '/search';
|
||||
}
|
||||
|
||||
bool needAuthChangeInitialized = true;
|
||||
|
@ -236,4 +238,12 @@ final appRouter = GoRouter(
|
|||
builder: (context, state) =>
|
||||
UserProfileScreen(userId: state.params['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: ScreenPaths.search,
|
||||
name: ScreenPaths.search,
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
name: ScreenPaths.search,
|
||||
child: SearchScreen(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
|
@ -10,6 +10,7 @@ import '../controls/standard_app_drawer.dart';
|
|||
import '../globals.dart';
|
||||
import '../models/connection.dart';
|
||||
import '../routes.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/connections_manager.dart';
|
||||
import '../services/network_status_service.dart';
|
||||
import '../utils/active_profile_selector.dart';
|
||||
|
@ -26,6 +27,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nss = getIt<NetworkStatusService>();
|
||||
final activeProfile = context.watch<AccountsService>();
|
||||
final manager = context
|
||||
.watch<ActiveProfileSelector<ConnectionsManager>>()
|
||||
.activeEntry
|
||||
|
@ -67,49 +69,55 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
itemCount: contacts.length);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: buildCurrentProfileButton(context),
|
||||
title: TextField(
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
filterText = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Filter By Name',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
drawer: StandardAppDrawer(),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
if (nss.connectionUpdateStatus.value) {
|
||||
return;
|
||||
}
|
||||
manager.updateAllContacts();
|
||||
return Scaffold(
|
||||
drawer: StandardAppDrawer(skipPopDismiss: true),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
if (nss.connectionUpdateStatus.value) {
|
||||
return;
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
StandardLinearProgressIndicator(nss.connectionUpdateStatus),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
}
|
||||
manager.updateAllContacts();
|
||||
return;
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50.0, child: buildCurrentProfileButton(context)!),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
filterText = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Filter By Name',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
StandardLinearProgressIndicator(nss.connectionUpdateStatus),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentButton: NavBarButtons.contacts,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentButton: NavBarButtons.contacts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -94,13 +94,6 @@ class _FollowRequestAdjudicationScreenState
|
|||
ConnectionsManager connectionsManager,
|
||||
FollowRequestsManager followRequestsManager,
|
||||
) {
|
||||
// Options are:
|
||||
// Accept and follow back
|
||||
// Accept and don't follow back
|
||||
// Reject
|
||||
// Back with no action
|
||||
// Calling method should check if completed (true) or not (false) to decide if updating their view of that item
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
@ -130,13 +123,6 @@ class _FollowRequestAdjudicationScreenState
|
|||
followRequestsManager, contact, true),
|
||||
child: const Text('Accept and follow back'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: processing
|
||||
? null
|
||||
: () async => await accept(connectionsManager,
|
||||
followRequestsManager, contact, true),
|
||||
child: const Text('Accept and follow back'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: processing
|
||||
? null
|
||||
|
|
|
@ -11,6 +11,7 @@ import '../models/connection.dart';
|
|||
import '../models/exec_error.dart';
|
||||
import '../models/interaction_type_enum.dart';
|
||||
import '../routes.dart';
|
||||
import '../services/connections_manager.dart';
|
||||
import '../services/interactions_manager.dart';
|
||||
import '../services/network_status_service.dart';
|
||||
import '../utils/active_profile_selector.dart';
|
||||
|
@ -66,9 +67,19 @@ class InteractionsViewerScreen extends StatelessWidget {
|
|||
itemBuilder: (context, index) {
|
||||
final connection = connections[index];
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
context.pushNamed(ScreenPaths.userProfile,
|
||||
params: {'id': connection.id});
|
||||
onTap: () async {
|
||||
await getIt<ActiveProfileSelector<ConnectionsManager>>()
|
||||
.activeEntry
|
||||
.andThenSuccessAsync((cm) async {
|
||||
final existingData = cm.getById(connection.id);
|
||||
if (existingData.isFailure) {
|
||||
await cm.fullRefresh(connection);
|
||||
}
|
||||
});
|
||||
if (context.mounted) {
|
||||
context.pushNamed(ScreenPaths.userProfile,
|
||||
params: {'id': connection.id});
|
||||
}
|
||||
},
|
||||
leading: ImageControl(
|
||||
imageUrl: connection.avatarUrl.toString(),
|
||||
|
|
342
lib/screens/search_screen.dart
Normal file
342
lib/screens/search_screen.dart
Normal file
|
@ -0,0 +1,342 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../controls/app_bottom_nav_bar.dart';
|
||||
import '../controls/current_profile_button.dart';
|
||||
import '../controls/image_control.dart';
|
||||
import '../controls/search_result_status_control.dart';
|
||||
import '../controls/standard_app_drawer.dart';
|
||||
import '../friendica_client/friendica_client.dart';
|
||||
import '../friendica_client/paging_data.dart';
|
||||
import '../globals.dart';
|
||||
import '../models/auth/profile.dart';
|
||||
import '../models/connection.dart';
|
||||
import '../models/search_results.dart';
|
||||
import '../models/search_types.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../routes.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/connections_manager.dart';
|
||||
import '../services/entry_manager_service.dart';
|
||||
import '../services/network_status_service.dart';
|
||||
import '../utils/active_profile_selector.dart';
|
||||
import '../utils/snackbar_builder.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
@override
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> {
|
||||
static const limit = 50;
|
||||
static final _logger = Logger('$SearchScreen');
|
||||
var searchText = '';
|
||||
var searchType = SearchTypes.statusesText;
|
||||
var searching = false;
|
||||
PagingData nextPage = PagingData(limit: limit);
|
||||
var searchResult = SearchResults.empty();
|
||||
|
||||
PagingData genNextPageData() {
|
||||
late final offset;
|
||||
switch (searchType) {
|
||||
case SearchTypes.hashTag:
|
||||
offset = searchResult.hashtags.length;
|
||||
break;
|
||||
case SearchTypes.account:
|
||||
offset = searchResult.accounts.length;
|
||||
break;
|
||||
case SearchTypes.statusesText:
|
||||
offset = searchResult.statuses.length;
|
||||
break;
|
||||
case SearchTypes.directLink:
|
||||
offset = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return PagingData(limit: limit, offset: offset);
|
||||
}
|
||||
|
||||
Future<void> updateSearchResults(Profile profile, {bool reset = true}) async {
|
||||
print('Starting update');
|
||||
if (reset) {
|
||||
nextPage = PagingData(limit: limit);
|
||||
searchResult = SearchResults.empty();
|
||||
}
|
||||
setState(() {
|
||||
searching = true;
|
||||
});
|
||||
|
||||
print('Search $searchType on $searchText');
|
||||
final result =
|
||||
await SearchClient(profile).search(searchType, searchText, nextPage);
|
||||
result.match(
|
||||
onSuccess: (result) {
|
||||
searchResult = reset ? result.data : searchResult.merge(result.data);
|
||||
nextPage = result.next ?? genNextPageData();
|
||||
},
|
||||
onError: (error) =>
|
||||
buildSnackbar(context, 'Error getting search result: $error'),
|
||||
);
|
||||
setState(() {
|
||||
searching = false;
|
||||
});
|
||||
print('Ending update');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.info('Build');
|
||||
final nss = getIt<NetworkStatusService>();
|
||||
final profileService = context.watch<AccountsService>();
|
||||
final profile = profileService.currentProfile;
|
||||
late Widget body;
|
||||
|
||||
if (searchResult.isEmpty && searching) {
|
||||
body = Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Searching for ${searchType.toLabel()} on: $searchText',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
body = buildResultBody(profile);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
drawer: StandardAppDrawer(skipPopDismiss: true),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
if (nss.searchLoadingStatus.value) {
|
||||
return;
|
||||
}
|
||||
updateSearchResults(profile);
|
||||
return;
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50.0, child: buildCurrentProfileButton(context)!),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
onChanged: (value) {
|
||||
searchText = value;
|
||||
},
|
||||
onEditingComplete: () {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
searchText = value;
|
||||
updateSearchResults(profile);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: searchType == SearchTypes.directLink
|
||||
? 'URL'
|
||||
: '${searchType.toLabel()} Search Text',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
updateSearchResults(profile);
|
||||
},
|
||||
child: const Text('Search'),
|
||||
),
|
||||
PopupMenuButton<SearchTypes>(
|
||||
initialValue: searchType,
|
||||
onSelected: (type) {
|
||||
setState(() {
|
||||
searchType = type;
|
||||
});
|
||||
},
|
||||
itemBuilder: (_) => SearchTypes.values
|
||||
.map((e) => PopupMenuItem(
|
||||
value: e,
|
||||
child: Text(
|
||||
e.toLabel(),
|
||||
)))
|
||||
.toList()),
|
||||
],
|
||||
),
|
||||
if (searching) const LinearProgressIndicator(),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentButton: NavBarButtons.contacts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildResultBody(Profile profile) {
|
||||
_logger.fine('Building search result body with: $searchResult');
|
||||
switch (searchType) {
|
||||
case SearchTypes.hashTag:
|
||||
return buildHashtagResultWidget(profile);
|
||||
case SearchTypes.account:
|
||||
return buildAccountResultWidget(profile);
|
||||
case SearchTypes.statusesText:
|
||||
return buildStatusResultWidget(profile);
|
||||
case SearchTypes.directLink:
|
||||
return buildDirectLinkResult(profile);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildHashtagResultWidget(Profile profile) {
|
||||
final hashtags = searchResult.hashtags;
|
||||
if (hashtags.isEmpty) {
|
||||
return buildEmptyResult();
|
||||
}
|
||||
return ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == hashtags.length) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
updateSearchResults(profile, reset: false);
|
||||
},
|
||||
child: const Text('Load more results'),
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
title: Text(hashtags[index]),
|
||||
);
|
||||
},
|
||||
itemCount: hashtags.length + 1,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAccountResultWidget(Profile profile) {
|
||||
final accounts = searchResult.accounts;
|
||||
if (accounts.isEmpty) {
|
||||
return buildEmptyResult();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemBuilder: (_, index) {
|
||||
if (index == accounts.length) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
updateSearchResults(profile, reset: false);
|
||||
},
|
||||
child: const Text('Load more results'),
|
||||
);
|
||||
}
|
||||
|
||||
return buildConnectionListTile(accounts[index]);
|
||||
},
|
||||
itemCount: accounts.length + 1,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStatusResultWidget(Profile profile) {
|
||||
final statuses = searchResult.statuses;
|
||||
if (statuses.isEmpty) {
|
||||
return buildEmptyResult();
|
||||
}
|
||||
return ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == statuses.length) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
updateSearchResults(profile, reset: false);
|
||||
},
|
||||
child: const Text('Load more results'),
|
||||
);
|
||||
}
|
||||
return buildStatusListTile(statuses[index]);
|
||||
},
|
||||
itemCount: statuses.length + 1,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDirectLinkResult(Profile profile) {
|
||||
if (searchResult.isEmpty) {
|
||||
return buildEmptyResult();
|
||||
}
|
||||
|
||||
return ListView(physics: const AlwaysScrollableScrollPhysics(), children: [
|
||||
if (searchResult.statuses.isNotEmpty)
|
||||
buildStatusListTile(searchResult.statuses.first),
|
||||
if (searchResult.accounts.isNotEmpty)
|
||||
buildConnectionListTile(searchResult.accounts.first),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget buildEmptyResult() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(searchText.isEmpty
|
||||
? 'Type search text to search'
|
||||
: 'No results for ${searchType.toLabel()} search on: $searchText'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildConnectionListTile(Connection connection) {
|
||||
return ListTile(
|
||||
onTap: () async {
|
||||
await getIt<ActiveProfileSelector<ConnectionsManager>>()
|
||||
.activeEntry
|
||||
.andThenSuccessAsync((cm) async {
|
||||
final existingData = cm.getById(connection.id);
|
||||
if (existingData.isFailure) {
|
||||
await cm.fullRefresh(connection);
|
||||
}
|
||||
});
|
||||
if (context.mounted) {
|
||||
context.pushNamed(ScreenPaths.userProfile,
|
||||
params: {'id': connection.id});
|
||||
}
|
||||
},
|
||||
leading: ImageControl(
|
||||
imageUrl: connection.avatarUrl.toString(),
|
||||
iconOverride: const Icon(Icons.person),
|
||||
width: 32.0,
|
||||
onTap: () => context
|
||||
.pushNamed(ScreenPaths.userProfile, params: {'id': connection.id}),
|
||||
),
|
||||
title: Text('${connection.name} (${connection.handle})'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStatusListTile(TimelineEntry status) {
|
||||
return SearchResultStatusControl(status, () async {
|
||||
final result = await getIt<ActiveProfileSelector<EntryManagerService>>()
|
||||
.activeEntry
|
||||
.andThenAsync((em) async => em.refreshStatusChain(status.id));
|
||||
if (context.mounted) {
|
||||
result.match(
|
||||
onSuccess: (entry) =>
|
||||
context.push('/post/view/${entry.id}/${status.id}'),
|
||||
onError: (error) =>
|
||||
buildSnackbar(context, 'Error getting post: $error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import '../../models/search_results.dart';
|
||||
import 'connection_mastodon_extensions.dart';
|
||||
import 'timeline_entry_mastodon_extensions.dart';
|
||||
|
||||
extension SearchResultMastodonExtensions on SearchResults {
|
||||
static SearchResults fromJson(Map<String, dynamic> json) {
|
||||
final accounts = (json['accounts'] as List<dynamic>? ?? [])
|
||||
.map((j) => ConnectionMastodonExtensions.fromJson(j))
|
||||
.toList();
|
||||
|
||||
final statuses = (json['statuses'] as List<dynamic>? ?? [])
|
||||
.map((j) => TimelineEntryMastodonExtensions.fromJson(j))
|
||||
.toList();
|
||||
|
||||
final hashtags = (json['hashtags'] as List<dynamic>? ?? [])
|
||||
.map((j) => j.toString())
|
||||
.toList();
|
||||
|
||||
return SearchResults(
|
||||
accounts: accounts,
|
||||
statuses: statuses,
|
||||
hashtags: hashtags,
|
||||
);
|
||||
}
|
||||
}
|
83
lib/services/fediverse_server_validator.dart
Normal file
83
lib/services/fediverse_server_validator.dart
Normal file
|
@ -0,0 +1,83 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:result_monad/result_monad.dart';
|
||||
|
||||
import '../models/exec_error.dart';
|
||||
import '../models/server_data.dart';
|
||||
import '../utils/network_utils.dart';
|
||||
|
||||
class FediverseServiceValidator {
|
||||
final knownServers = <String, ServerData>{};
|
||||
|
||||
FutureResult<bool, ExecError> checkIfFediverseLink(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
return buildErrorResult(
|
||||
type: ErrorType.parsingError,
|
||||
message: 'Invalid URL: $url',
|
||||
);
|
||||
}
|
||||
|
||||
final domain = uri.host;
|
||||
ServerData? data = knownServers[domain];
|
||||
if (data != null) {
|
||||
return Result.ok(data.isFediverse);
|
||||
}
|
||||
|
||||
final serverData = await refreshServerData(domain);
|
||||
knownServers[domain] = serverData;
|
||||
return Result.ok(serverData.isFediverse);
|
||||
}
|
||||
|
||||
static Future<ServerData> refreshServerData(String domainName) async {
|
||||
final uri = Uri.https(
|
||||
domainName,
|
||||
'/.well-known/nodeinfo',
|
||||
);
|
||||
final result = await getUrl(uri)
|
||||
.andThenSuccessAsync((page) async {
|
||||
return jsonDecode(page.data);
|
||||
})
|
||||
.andThenAsync(
|
||||
(json) async =>
|
||||
json is Map<String, dynamic> ? Result.ok(json) : Result.error(''),
|
||||
)
|
||||
.andThenSuccessAsync((json) async => json['links'] ?? [])
|
||||
.andThenAsync(
|
||||
(nodeInfos) async => nodeInfos.isNotEmpty
|
||||
? Result.ok(nodeInfos.last)
|
||||
: Result.error(''),
|
||||
)
|
||||
.andThenAsync((nodeInfo) async {
|
||||
final rel = nodeInfo['rel']?.toString() ?? '';
|
||||
if (!rel.startsWith('http://nodeinfo.diaspora.software/ns/schema/')) {
|
||||
return Result.error('');
|
||||
}
|
||||
|
||||
final nodeInfoUrl = Uri.tryParse(nodeInfo['href'] ?? '');
|
||||
if (nodeInfoUrl == null) {
|
||||
return Result.error('');
|
||||
}
|
||||
return await getUrl(nodeInfoUrl);
|
||||
})
|
||||
.andThenSuccessAsync(
|
||||
(nodeInfoData) async => jsonDecode(nodeInfoData.data))
|
||||
.andThenSuccessAsync((nodeInfoJson) async {
|
||||
final softwareName =
|
||||
nodeInfoJson['software']?['name']?.toString() ?? '';
|
||||
final softwareVersion =
|
||||
nodeInfoJson['software']?['version']?.toString() ?? '';
|
||||
final isFediverse =
|
||||
softwareName.isNotEmpty && softwareVersion.isNotEmpty;
|
||||
return ServerData(
|
||||
domainName: domainName,
|
||||
isFediverse: isFediverse,
|
||||
softwareName: softwareName,
|
||||
softwareVersion: softwareVersion,
|
||||
);
|
||||
});
|
||||
|
||||
return result.getValueOrElse(
|
||||
() => ServerData(domainName: domainName, isFediverse: false));
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ class NetworkStatusService {
|
|||
final interactionsLoadingStatus = ValueNotifier<bool>(false);
|
||||
final timelineLoadingStatus = ValueNotifier<bool>(false);
|
||||
final imageGalleryLoadingStatus = ValueNotifier<bool>(false);
|
||||
final searchLoadingStatus = ValueNotifier<bool>(false);
|
||||
|
||||
void startConnectionUpdateStatus() {
|
||||
connectionUpdateStatus.value = true;
|
||||
|
@ -55,4 +56,12 @@ class NetworkStatusService {
|
|||
void finishInteractionsLoading() {
|
||||
interactionsLoadingStatus.value = false;
|
||||
}
|
||||
|
||||
void startSearchLoading() {
|
||||
searchLoadingStatus.value = true;
|
||||
}
|
||||
|
||||
void finishSearchLoaing() {
|
||||
searchLoadingStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,21 @@ class ActiveProfileSelector<T> extends ChangeNotifier {
|
|||
|
||||
final T Function(Profile p)? _entryBuilder;
|
||||
|
||||
bool _subscribeAdded = false;
|
||||
|
||||
ActiveProfileSelector(T Function(Profile p)? entryBuilder)
|
||||
: _entryBuilder = entryBuilder;
|
||||
|
||||
void subscribeToProfileSwaps() {
|
||||
if (_subscribeAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
getIt<AccountsService>().addListener(() {
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
bool get canCreateOnDemand => _entryBuilder != null;
|
||||
|
||||
Result<T, ExecError> get activeEntry {
|
||||
|
|
111
lib/utils/network_utils.dart
Normal file
111
lib/utils/network_utils.dart
Normal file
|
@ -0,0 +1,111 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:result_monad/result_monad.dart';
|
||||
|
||||
import '../friendica_client/paged_response.dart';
|
||||
import '../models/exec_error.dart';
|
||||
|
||||
final _logger = Logger('NetworkUtils');
|
||||
|
||||
FutureResult<PagedResponse<String>, ExecError> getUrl(
|
||||
Uri url, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
_logger.finer('GET: $url');
|
||||
try {
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return PagedResponse.fromLinkHeader(
|
||||
response.headers['link'],
|
||||
utf8.decode(response.bodyBytes),
|
||||
);
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> postUrl(
|
||||
Uri url,
|
||||
Map<String, dynamic> body, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
_logger.finer('POST: $url \n Body: $body');
|
||||
try {
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> putUrl(
|
||||
Uri url,
|
||||
Map<String, dynamic> body, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
_logger.finer('PUT: $url \n Body: $body');
|
||||
try {
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> deleteUrl(
|
||||
Uri url,
|
||||
Map<String, dynamic> body, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
_logger.finer('DELETE: $url');
|
||||
try {
|
||||
final response = await http.delete(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
61
test/fediverse_server_validator_test.dart
Normal file
61
test/fediverse_server_validator_test.dart
Normal file
|
@ -0,0 +1,61 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relatica/services/fediverse_server_validator.dart';
|
||||
|
||||
void main() {
|
||||
test('Test against Diaspora Server', () async {
|
||||
await testDomain('diasp.org', 'diaspora');
|
||||
});
|
||||
|
||||
test('Test against Friendica Server', () async {
|
||||
await testDomain('friendica.myportal.social', 'friendica');
|
||||
});
|
||||
|
||||
test('Test against Mastodon Server', () async {
|
||||
await testDomain('mastodon.social', 'mastodon');
|
||||
});
|
||||
|
||||
test('Test against GNUSocial Server', () async {
|
||||
await testDomain('gnusocial.net', 'gnusocial');
|
||||
});
|
||||
|
||||
test('Test against MissKey Server', () async {
|
||||
await testDomain('misskey.io', 'misskey');
|
||||
});
|
||||
|
||||
test('Test against HubZilla Server', () async {
|
||||
await testDomain('hub.hubzilla.de', 'redmatrix');
|
||||
});
|
||||
|
||||
test('Test against PeerTube Server', () async {
|
||||
await testDomain('tilvids.com', 'peertube');
|
||||
});
|
||||
|
||||
test('Test against PixelFed Server', () async {
|
||||
await testDomain('pixels.gsi.li', 'pixelfed');
|
||||
});
|
||||
|
||||
test('Test against Funkwhale Server', () async {
|
||||
await testDomain('open.audio', 'funkwhale');
|
||||
});
|
||||
|
||||
test('Test against Akkoma Server', () async {
|
||||
await testDomain('social.kernel.org', 'akkoma');
|
||||
});
|
||||
|
||||
test('Test against Pleroma Server', () async {
|
||||
await testDomain('stereophonic.space', 'pleroma');
|
||||
});
|
||||
|
||||
test('Test against non-fediverse server', () async {
|
||||
final result =
|
||||
await FediverseServiceValidator.refreshServerData('myportal.social');
|
||||
expect(result.isFediverse, equals(false));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> testDomain(String domain, String softwareName) async {
|
||||
final result = await FediverseServiceValidator.refreshServerData(domain);
|
||||
expect(result.isFediverse, equals(true));
|
||||
expect(result.domainName, equals(domain));
|
||||
expect(result.softwareName, equals(softwareName));
|
||||
}
|
Loading…
Reference in a new issue