Merge branch 'main' of gitlab.com:mysocialportal/relatica

This commit is contained in:
Hank Grabowski 2023-03-22 09:42:32 -04:00
commit bbccbcfbf6
22 changed files with 1139 additions and 189 deletions

View file

@ -18,12 +18,19 @@
* Toggle like status on first level of comments no longer generates error * 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 * Can properly load private images if the access control list is set correctly on the server
* Proper paging of notifications on refresh * 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 * New Features
* Post/Comment Editing * Post/Comment Editing
* Being able to set the posts as private and a single group it is visible to (or "Followers", the default) * Being able to set the posts as private and a single group it is visible to (or "Followers", the default)
* Link Previews * Link Previews
* Use actual follow requests system not the "follow requests" in notifications if account is on server running * Use actual follow requests system not the "follow requests" in notifications if account is on server running
Friendica 2023.03 or later 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 ## Version 0.2.0 (beta), 15 March 2023

View file

@ -6,7 +6,6 @@ import 'package:provider/provider.dart';
import '../routes.dart'; import '../routes.dart';
import '../services/notifications_manager.dart'; import '../services/notifications_manager.dart';
import '../utils/active_profile_selector.dart'; import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
enum NavBarButtons { enum NavBarButtons {
timelines, timelines,
@ -51,7 +50,7 @@ class AppBottomNavBar extends StatelessWidget {
context.pushNamed(ScreenPaths.contacts); context.pushNamed(ScreenPaths.contacts);
break; break;
case NavBarButtons.search: case NavBarButtons.search:
buildSnackbar(context, 'Search screen coming soon...'); context.pushNamed(ScreenPaths.search);
break; break;
} }
}, },

View 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();
});
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import '../globals.dart'; import '../globals.dart';
import '../routes.dart'; import '../routes.dart';
@ -7,6 +8,15 @@ import '../services/auth_service.dart';
import 'login_aware_cached_network_image.dart'; import 'login_aware_cached_network_image.dart';
class StandardAppDrawer extends StatelessWidget { class StandardAppDrawer extends StatelessWidget {
final bool skipPopDismiss;
static final _logger = Logger('$StandardAppDrawer');
const StandardAppDrawer({
super.key,
this.skipPopDismiss = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
@ -18,8 +28,16 @@ class StandardAppDrawer extends StatelessWidget {
(p) => ListTile( (p) => ListTile(
onTap: () async { onTap: () async {
await getIt<AccountsService>().setActiveProfile(p); await getIt<AccountsService>().setActiveProfile(p);
if (context.mounted && context.canPop()) { if (!skipPopDismiss &&
context.pop(); 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( leading: CircleAvatar(

View file

@ -16,10 +16,12 @@ import '../padding.dart';
class StatusHeaderControl extends StatelessWidget { class StatusHeaderControl extends StatelessWidget {
static final _logger = Logger('$StatusHeaderControl'); static final _logger = Logger('$StatusHeaderControl');
final TimelineEntry entry; final TimelineEntry entry;
final bool showIsCommentText;
const StatusHeaderControl({ const StatusHeaderControl({
super.key, super.key,
required this.entry, required this.entry,
this.showIsCommentText = false,
}); });
void goToProfile(BuildContext context, String id) { 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( Row(

View file

@ -27,6 +27,7 @@ class TimelinePanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.finer('Build');
final manager = context final manager = context
.watch<ActiveProfileSelector<TimelineManager>>() .watch<ActiveProfileSelector<TimelineManager>>()
.activeEntry .activeEntry

View file

@ -16,6 +16,7 @@ import 'services/connections_manager.dart';
import 'services/direct_message_service.dart'; import 'services/direct_message_service.dart';
import 'services/entry_manager_service.dart'; import 'services/entry_manager_service.dart';
import 'services/feature_version_checker.dart'; import 'services/feature_version_checker.dart';
import 'services/fediverse_server_validator.dart';
import 'services/follow_requests_manager.dart'; import 'services/follow_requests_manager.dart';
import 'services/gallery_service.dart'; import 'services/gallery_service.dart';
import 'services/hashtag_service.dart'; import 'services/hashtag_service.dart';
@ -36,6 +37,8 @@ Future<void> dependencyInjectionInitialization() async {
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo()); getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());
getIt.registerSingleton<HashtagService>(HashtagService()); getIt.registerSingleton<HashtagService>(HashtagService());
getIt.registerSingleton<NetworkStatusService>(NetworkStatusService()); getIt.registerSingleton<NetworkStatusService>(NetworkStatusService());
getIt.registerSingleton<FediverseServiceValidator>(
FediverseServiceValidator());
getIt.registerSingleton<FriendicaVersionChecker>( getIt.registerSingleton<FriendicaVersionChecker>(
const FriendicaVersionChecker()); const FriendicaVersionChecker());
@ -52,16 +55,19 @@ Future<void> dependencyInjectionInitialization() async {
final secretsService = SecretsService(); final secretsService = SecretsService();
final serviceInit = await secretsService.initialize(); final serviceInit = await secretsService.initialize();
final authService = AccountsService(secretsService); final accountsService = AccountsService(secretsService);
if (serviceInit.isFailure) { if (serviceInit.isFailure) {
_logger.severe('Error initializing credentials'); _logger.severe('Error initializing credentials');
} else { } 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>>( getIt.registerSingleton<ActiveProfileSelector<IGroupsRepo>>(
ActiveProfileSelector((p) => MemoryGroupsRepo())); ActiveProfileSelector((p) => MemoryGroupsRepo())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<ConnectionsManager>>( getIt.registerSingleton<ActiveProfileSelector<ConnectionsManager>>(
ActiveProfileSelector( ActiveProfileSelector(
@ -72,24 +78,31 @@ Future<void> dependencyInjectionInitialization() async {
)); ));
getIt.registerSingleton<ActiveProfileSelector<GalleryService>>( getIt.registerSingleton<ActiveProfileSelector<GalleryService>>(
ActiveProfileSelector((p) => GalleryService())); ActiveProfileSelector((p) => GalleryService())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<EntryManagerService>>( getIt.registerSingleton<ActiveProfileSelector<EntryManagerService>>(
ActiveProfileSelector((p) => EntryManagerService())); ActiveProfileSelector((p) => EntryManagerService())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<TimelineManager>>( getIt.registerSingleton<ActiveProfileSelector<TimelineManager>>(
ActiveProfileSelector((p) => TimelineManager( ActiveProfileSelector((p) => TimelineManager(
getIt<ActiveProfileSelector<IGroupsRepo>>().getForProfile(p).value, getIt<ActiveProfileSelector<IGroupsRepo>>().getForProfile(p).value,
getIt<ActiveProfileSelector<EntryManagerService>>() getIt<ActiveProfileSelector<EntryManagerService>>()
.getForProfile(p) .getForProfile(p)
.value, .value,
))); ))
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<NotificationsManager>>( getIt.registerSingleton<ActiveProfileSelector<NotificationsManager>>(
ActiveProfileSelector((_) => NotificationsManager())); ActiveProfileSelector((_) => NotificationsManager())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<FollowRequestsManager>>( getIt.registerSingleton<ActiveProfileSelector<FollowRequestsManager>>(
ActiveProfileSelector((_) => FollowRequestsManager())); ActiveProfileSelector((_) => FollowRequestsManager())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<DirectMessageService>>( getIt.registerSingleton<ActiveProfileSelector<DirectMessageService>>(
ActiveProfileSelector((p) => DirectMessageService())); ActiveProfileSelector((p) => DirectMessageService())
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<InteractionsManager>>( getIt.registerSingleton<ActiveProfileSelector<InteractionsManager>>(
ActiveProfileSelector((p) => InteractionsManager())); ActiveProfileSelector((p) => InteractionsManager())
..subscribeToProfileSwaps());
getIt.registerLazySingleton<MediaUploadAttachmentHelper>( getIt.registerLazySingleton<MediaUploadAttachmentHelper>(
() => MediaUploadAttachmentHelper()); () => MediaUploadAttachmentHelper());

View file

@ -19,6 +19,8 @@ import '../models/group_data.dart';
import '../models/image_entry.dart'; import '../models/image_entry.dart';
import '../models/instance_info.dart'; import '../models/instance_info.dart';
import '../models/media_attachment_uploads/image_types_enum.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/timeline_entry.dart';
import '../models/user_notification.dart'; import '../models/user_notification.dart';
import '../models/visibility.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/group_data_mastodon_extensions.dart';
import '../serializers/mastodon/instance_info_mastodon_extensions.dart'; import '../serializers/mastodon/instance_info_mastodon_extensions.dart';
import '../serializers/mastodon/notification_mastodon_extension.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/timeline_entry_mastodon_extensions.dart';
import '../serializers/mastodon/visibility_mastodon_extensions.dart'; import '../serializers/mastodon/visibility_mastodon_extensions.dart';
import '../services/fediverse_server_validator.dart';
import '../services/network_status_service.dart'; import '../services/network_status_service.dart';
import '../utils/network_utils.dart';
import 'paging_data.dart'; import 'paging_data.dart';
class DirectMessagingClient extends FriendicaClient { class DirectMessagingClient extends FriendicaClient {
@ -65,8 +70,8 @@ class DirectMessagingClient extends FriendicaClient {
final id = message.id; final id = message.id;
final url = Uri.parse( final url = Uri.parse(
'https://$serverName/api/friendica/direct_messages_setseen?id=$id'); 'https://$serverName/api/friendica/direct_messages_setseen?id=$id');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return message.copy(seen: true); return message.copy(seen: true);
}); });
_networkStatusService.finishDirectMessageUpdateStatus(); _networkStatusService.finishDirectMessageUpdateStatus();
@ -85,7 +90,7 @@ class DirectMessagingClient extends FriendicaClient {
'text': text, 'text': text,
if (messageIdRepliedTo != null) 'replyto': messageIdRepliedTo, if (messageIdRepliedTo != null) 'replyto': messageIdRepliedTo,
}; };
final result = await _postUrl(url, body) final result = await postUrl(url, body, headers: _headers)
.andThenAsync<DirectMessage, ExecError>((jsonString) async { .andThenAsync<DirectMessage, ExecError>((jsonString) async {
final json = jsonDecode(jsonString) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
if (json.containsKey('error')) { if (json.containsKey('error')) {
@ -187,7 +192,8 @@ class GroupsClient extends FriendicaClient {
final requestData = { final requestData = {
'account_ids': [connection.id] 'account_ids': [connection.id]
}; };
return (await _postUrl(request, requestData)).mapValue((_) => true); return (await postUrl(request, requestData, headers: _headers))
.mapValue((_) => true);
} }
FutureResult<bool, ExecError> removeConnectionFromGroup( FutureResult<bool, ExecError> removeConnectionFromGroup(
@ -200,7 +206,8 @@ class GroupsClient extends FriendicaClient {
final requestData = { final requestData = {
'account_ids': [connection.id] '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 { String id, bool status) async {
final action = status ? 'favourite' : 'unfavourite'; final action = status ? 'favourite' : 'unfavourite';
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/$action'); 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) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
} }
@ -326,7 +333,7 @@ class NotificationsClient extends FriendicaClient {
final url = 'https://$serverName/api/v1/notifications/clear'; final url = 'https://$serverName/api/v1/notifications/clear';
final request = Uri.parse(url); final request = Uri.parse(url);
_logger.finest(() => 'Clearing unread notification'); _logger.finest(() => 'Clearing unread notification');
final response = await _postUrl(request, {}); final response = await postUrl(request, {}, headers: _headers);
return response.mapValue((value) => true); return response.mapValue((value) => true);
} }
@ -336,7 +343,7 @@ class NotificationsClient extends FriendicaClient {
'https://$serverName/api/v1/notifications/${notification.id}/dismiss'; 'https://$serverName/api/v1/notifications/${notification.id}/dismiss';
final request = Uri.parse(url); final request = Uri.parse(url);
_logger.finest(() => 'Clearing unread notification for $notification'); _logger.finest(() => 'Clearing unread notification for $notification');
final response = await _postUrl(request, {}); final response = await postUrl(request, {}, headers: _headers);
return response.mapValue((value) => true); return response.mapValue((value) => true);
} }
} }
@ -455,8 +462,8 @@ class RelationshipsClient extends FriendicaClient {
final id = connection.id; final id = connection.id;
final url = final url =
Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize'); Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return _updateConnectionFromFollowRequestResult(connection, jsonString); return _updateConnectionFromFollowRequestResult(connection, jsonString);
}); });
return result.mapError((error) => error is ExecError return result.mapError((error) => error is ExecError
@ -469,8 +476,8 @@ class RelationshipsClient extends FriendicaClient {
final id = connection.id; final id = connection.id;
final url = final url =
Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject'); Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return _updateConnectionFromFollowRequestResult(connection, jsonString); return _updateConnectionFromFollowRequestResult(connection, jsonString);
}); });
return result.mapError((error) => error is ExecError return result.mapError((error) => error is ExecError
@ -483,8 +490,8 @@ class RelationshipsClient extends FriendicaClient {
final id = connection.id; final id = connection.id;
final url = final url =
Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore'); Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return _updateConnectionFromFollowRequestResult(connection, jsonString); return _updateConnectionFromFollowRequestResult(connection, jsonString);
}); });
return result.mapError((error) => error is ExecError return result.mapError((error) => error is ExecError
@ -496,8 +503,8 @@ class RelationshipsClient extends FriendicaClient {
Connection connection) async { Connection connection) async {
final id = connection.id; final id = connection.id;
final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow'); final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return _updateConnectionFromFollowRequestResult(connection, jsonString); return _updateConnectionFromFollowRequestResult(connection, jsonString);
}); });
return result.mapError((error) => error is ExecError return result.mapError((error) => error is ExecError
@ -509,8 +516,8 @@ class RelationshipsClient extends FriendicaClient {
Connection connection) async { Connection connection) async {
final id = connection.id; final id = connection.id;
final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow'); final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow');
final result = final result = await postUrl(url, {}, headers: _headers)
await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { .andThenSuccessAsync((jsonString) async {
return _updateConnectionFromFollowRequestResult(connection, jsonString); return _updateConnectionFromFollowRequestResult(connection, jsonString);
}); });
return result.mapError((error) => error is ExecError return result.mapError((error) => error is ExecError
@ -680,7 +687,7 @@ class StatusesClient extends FriendicaClient {
'title': '', 'title': '',
}, },
}; };
final result = await _postUrl(url, body); final result = await postUrl(url, body, headers: _headers);
if (result.isFailure) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
} }
@ -711,7 +718,7 @@ class StatusesClient extends FriendicaClient {
'title': '', 'title': '',
}, },
}; };
final result = await _putUrl(url, body); final result = await putUrl(url, body, headers: _headers);
if (result.isFailure) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
} }
@ -729,7 +736,7 @@ class StatusesClient extends FriendicaClient {
FutureResult<TimelineEntry, ExecError> resharePost(String id) async { FutureResult<TimelineEntry, ExecError> resharePost(String id) async {
_logger.finest(() => 'Reshare post $id'); _logger.finest(() => 'Reshare post $id');
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog'); 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) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
} }
@ -747,7 +754,7 @@ class StatusesClient extends FriendicaClient {
FutureResult<TimelineEntry, ExecError> unResharePost(String id) async { FutureResult<TimelineEntry, ExecError> unResharePost(String id) async {
_logger.finest(() => 'Reshare post $id'); _logger.finest(() => 'Reshare post $id');
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/unreblog'); 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) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
} }
@ -766,7 +773,50 @@ class StatusesClient extends FriendicaClient {
_logger.finest(() => 'Deleting post/comment $id'); _logger.finest(() => 'Deleting post/comment $id');
final url = 'https://$serverName/api/v1/statuses/$id'; final url = 'https://$serverName/api/v1/statuses/$id';
final request = Uri.parse(url); 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 { abstract class FriendicaClient {
static final _logger = Logger('$FriendicaClient');
final Profile _profile; final Profile _profile;
late final NetworkStatusService _networkStatusService; late final NetworkStatusService _networkStatusService;
@ -861,112 +909,31 @@ abstract class FriendicaClient {
_networkStatusService = getIt<NetworkStatusService>(); _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( FutureResult<PagedResponse<List<dynamic>>, ExecError> _getApiListRequest(
Uri url) async { Uri url) async {
return (await _getUrl(url).andThenSuccessAsync( return (await getUrl(url, headers: _headers).andThenSuccessAsync(
(response) async => (response) async =>
response.map((data) => jsonDecode(data) as List<dynamic>), response.map((data) => jsonDecode(data) as List<dynamic>),
)) ))
.mapError((error) => error as ExecError); .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 { FutureResult<dynamic, ExecError> _getApiRequest(Uri url) async {
return (await _getUrl(url).andThenSuccessAsync( return (await getUrl(url, headers: _headers).andThenSuccessAsync(
(response) async => jsonDecode(response.data), (response) async => jsonDecode(response.data),
)) ))
.execErrorCastAsync(); .execErrorCastAsync();
} }
Map<String, String> get _header => { Map<String, String> get _headers => {
'Authorization': _profile.credentials.authHeaderValue, 'Authorization': _profile.credentials.authHeaderValue,
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',
if (usePhpDebugging) 'Cookie': 'XDEBUG_SESSION=PHPSTORM;path=/', if (usePhpDebugging) 'Cookie': 'XDEBUG_SESSION=PHPSTORM;path=/',

View 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}}';
}
}

View 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';
}
}
}

View 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>[];
}

View file

@ -14,6 +14,7 @@ import 'screens/message_threads_browser_screen.dart';
import 'screens/messages_new_thread.dart'; import 'screens/messages_new_thread.dart';
import 'screens/notifications_screen.dart'; import 'screens/notifications_screen.dart';
import 'screens/post_screen.dart'; import 'screens/post_screen.dart';
import 'screens/search_screen.dart';
import 'screens/settings_screen.dart'; import 'screens/settings_screen.dart';
import 'screens/sign_in.dart'; import 'screens/sign_in.dart';
import 'screens/splash.dart'; import 'screens/splash.dart';
@ -38,6 +39,7 @@ class ScreenPaths {
static String userPosts = '/user_posts'; static String userPosts = '/user_posts';
static String likes = '/likes'; static String likes = '/likes';
static String reshares = '/reshares'; static String reshares = '/reshares';
static String search = '/search';
} }
bool needAuthChangeInitialized = true; bool needAuthChangeInitialized = true;
@ -236,4 +238,12 @@ final appRouter = GoRouter(
builder: (context, state) => builder: (context, state) =>
UserProfileScreen(userId: state.params['id']!), UserProfileScreen(userId: state.params['id']!),
), ),
GoRoute(
path: ScreenPaths.search,
name: ScreenPaths.search,
pageBuilder: (context, state) => NoTransitionPage(
name: ScreenPaths.search,
child: SearchScreen(),
),
),
]); ]);

View file

@ -10,6 +10,7 @@ import '../controls/standard_app_drawer.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/connection.dart'; import '../models/connection.dart';
import '../routes.dart'; import '../routes.dart';
import '../services/auth_service.dart';
import '../services/connections_manager.dart'; import '../services/connections_manager.dart';
import '../services/network_status_service.dart'; import '../services/network_status_service.dart';
import '../utils/active_profile_selector.dart'; import '../utils/active_profile_selector.dart';
@ -26,6 +27,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final nss = getIt<NetworkStatusService>(); final nss = getIt<NetworkStatusService>();
final activeProfile = context.watch<AccountsService>();
final manager = context final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>() .watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry .activeEntry
@ -67,49 +69,55 @@ class _ContactsScreenState extends State<ContactsScreen> {
itemCount: contacts.length); itemCount: contacts.length);
} }
return SafeArea( return Scaffold(
child: Scaffold( drawer: StandardAppDrawer(skipPopDismiss: true),
appBar: AppBar( body: SafeArea(
leading: buildCurrentProfileButton(context), child: RefreshIndicator(
title: TextField( onRefresh: () async {
onChanged: (value) { if (nss.connectionUpdateStatus.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; return;
}, }
child: Column( manager.updateAllContacts();
children: [ return;
StandardLinearProgressIndicator(nss.connectionUpdateStatus), },
Expanded(child: body), 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,
), ),
); );
} }

View file

@ -94,13 +94,6 @@ class _FollowRequestAdjudicationScreenState
ConnectionsManager connectionsManager, ConnectionsManager connectionsManager,
FollowRequestsManager followRequestsManager, 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( return SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@ -130,13 +123,6 @@ class _FollowRequestAdjudicationScreenState
followRequestsManager, contact, true), followRequestsManager, contact, true),
child: const Text('Accept and follow back'), 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( ElevatedButton(
onPressed: processing onPressed: processing
? null ? null

View file

@ -11,6 +11,7 @@ import '../models/connection.dart';
import '../models/exec_error.dart'; import '../models/exec_error.dart';
import '../models/interaction_type_enum.dart'; import '../models/interaction_type_enum.dart';
import '../routes.dart'; import '../routes.dart';
import '../services/connections_manager.dart';
import '../services/interactions_manager.dart'; import '../services/interactions_manager.dart';
import '../services/network_status_service.dart'; import '../services/network_status_service.dart';
import '../utils/active_profile_selector.dart'; import '../utils/active_profile_selector.dart';
@ -66,9 +67,19 @@ class InteractionsViewerScreen extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final connection = connections[index]; final connection = connections[index];
return ListTile( return ListTile(
onTap: () { onTap: () async {
context.pushNamed(ScreenPaths.userProfile, await getIt<ActiveProfileSelector<ConnectionsManager>>()
params: {'id': connection.id}); .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( leading: ImageControl(
imageUrl: connection.avatarUrl.toString(), imageUrl: connection.avatarUrl.toString(),

View 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'));
}
});
}
}

View file

@ -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,
);
}
}

View 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));
}
}

View file

@ -7,6 +7,7 @@ class NetworkStatusService {
final interactionsLoadingStatus = ValueNotifier<bool>(false); final interactionsLoadingStatus = ValueNotifier<bool>(false);
final timelineLoadingStatus = ValueNotifier<bool>(false); final timelineLoadingStatus = ValueNotifier<bool>(false);
final imageGalleryLoadingStatus = ValueNotifier<bool>(false); final imageGalleryLoadingStatus = ValueNotifier<bool>(false);
final searchLoadingStatus = ValueNotifier<bool>(false);
void startConnectionUpdateStatus() { void startConnectionUpdateStatus() {
connectionUpdateStatus.value = true; connectionUpdateStatus.value = true;
@ -55,4 +56,12 @@ class NetworkStatusService {
void finishInteractionsLoading() { void finishInteractionsLoading() {
interactionsLoadingStatus.value = false; interactionsLoadingStatus.value = false;
} }
void startSearchLoading() {
searchLoadingStatus.value = true;
}
void finishSearchLoaing() {
searchLoadingStatus.value = false;
}
} }

View file

@ -11,9 +11,21 @@ class ActiveProfileSelector<T> extends ChangeNotifier {
final T Function(Profile p)? _entryBuilder; final T Function(Profile p)? _entryBuilder;
bool _subscribeAdded = false;
ActiveProfileSelector(T Function(Profile p)? entryBuilder) ActiveProfileSelector(T Function(Profile p)? entryBuilder)
: _entryBuilder = entryBuilder; : _entryBuilder = entryBuilder;
void subscribeToProfileSwaps() {
if (_subscribeAdded) {
return;
}
getIt<AccountsService>().addListener(() {
notifyListeners();
});
}
bool get canCreateOnDemand => _entryBuilder != null; bool get canCreateOnDemand => _entryBuilder != null;
Result<T, ExecError> get activeEntry { Result<T, ExecError> get activeEntry {

View 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()));
}
}

View 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));
}