import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import '../friendica_client/paged_response.dart'; import '../globals.dart'; import '../models/TimelineIdentifiers.dart'; import '../models/auth/profile.dart'; import '../models/circle_data.dart'; import '../models/connection.dart'; import '../models/direct_message.dart'; import '../models/exec_error.dart'; import '../models/follow_request.dart'; import '../models/gallery_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'; import '../serializers/friendica/direct_message_friendica_extensions.dart'; import '../serializers/friendica/gallery_data_friendica_extensions.dart'; import '../serializers/friendica/image_entry_friendica_extensions.dart'; import '../serializers/friendica/visibility_friendica_extensions.dart'; import '../serializers/mastodon/circle_data_mastodon_extensions.dart'; import '../serializers/mastodon/connection_mastodon_extensions.dart'; import '../serializers/mastodon/follow_request_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 { static final _logger = Logger('$DirectMessagingClient'); DirectMessagingClient(super.credentials) : super(); FutureResult, ExecError> getDirectMessages( PagingData page) async { _networkStatusService.startDirectMessageUpdateStatus(); final baseUrl = 'https://$serverName/api/direct_messages/all'; final pagingQP = page.toQueryParameters(limitKeyword: 'count'); final url = '$baseUrl?$pagingQP'; final request = Uri.parse(url); _logger.finest(() => 'Getting direct messages with paging data $page'); final result = (await _getApiListRequest(request).andThenSuccessAsync( (response) async => response.data .map((json) => DirectMessageFriendicaExtension.fromJson(json)) .toList())) .execErrorCast(); _networkStatusService.finishDirectMessageUpdateStatus(); return result; } FutureResult markDirectMessageRead( DirectMessage message) async { _networkStatusService.startDirectMessageUpdateStatus(); final id = message.id; final url = Uri.parse( 'https://$serverName/api/friendica/direct_messages_setseen?id=$id'); final result = await postUrl(url, {}, headers: _headers) .andThenSuccessAsync((jsonString) async { return message.copy(seen: true); }); _networkStatusService.finishDirectMessageUpdateStatus(); return result.execErrorCast(); } FutureResult postDirectMessage( String? messageIdRepliedTo, String receivingUserId, String text, ) async { _networkStatusService.startDirectMessageUpdateStatus(); final url = Uri.parse('https://$serverName/api/direct_messages/new'); final body = { 'user_id': receivingUserId, 'text': text, if (messageIdRepliedTo != null) 'replyto': messageIdRepliedTo, }; final result = await postUrl(url, body, headers: _headers) .andThenAsync((jsonString) async { final json = jsonDecode(jsonString) as Map; if (json.containsKey('error')) { return buildErrorResult( type: ErrorType.serverError, message: "Error from server: ${json['error']}"); } return Result.ok( DirectMessageFriendicaExtension.fromJson(jsonDecode(jsonString))); }); _networkStatusService.finishDirectMessageUpdateStatus(); return result.execErrorCast(); } } class GalleryClient extends FriendicaClient { static final _logger = Logger('$GalleryClient'); GalleryClient(super.credentials) : super(); // TODO Convert Albums to using paging for real FutureResult, ExecError> getGalleryData() async { _networkStatusService.startGalleryLoading(); _logger.finest(() => 'Getting gallery data'); final url = 'https://$serverName/api/friendica/photoalbums'; final request = Uri.parse(url); final result = (await _getApiListRequest(request).andThenSuccessAsync( (albumsJson) async => albumsJson.data .map((json) => GalleryDataFriendicaExtensions.fromJson(json)) .toList())) .execErrorCast(); _networkStatusService.finishGalleryLoading(); return result; } // TODO Convert Gallery Images to using paging for real once server side available FutureResult, ExecError> getGalleryImages( String galleryName, PagingData page, ) async { _networkStatusService.startGalleryLoading(); _logger.finest(() => 'Getting gallery $galleryName data with page: $page'); final baseUrl = 'https://$serverName/api/friendica/photoalbum?'; final gallery = 'album=$galleryName&latest_first=true'; final pageParams = page.toQueryParameters(); final url = '$baseUrl$gallery&$pageParams'; final request = Uri.parse(url); final result = (await _getApiListRequest(request).andThenSuccessAsync( (imagesJson) async => imagesJson.data .map((json) => ImageEntryFriendicaExtension.fromJson(json)) .toList())) .execErrorCast(); _networkStatusService.finishGalleryLoading(); return result; } FutureResult renameGallery( String oldGalleryName, String newGalleryName) async { _networkStatusService.startGalleryLoading(); _logger.finest(() => 'Getting gallery data'); final url = Uri.parse('https://$serverName/api/friendica/photoalbum/update'); final body = { 'album': oldGalleryName, 'album_new': newGalleryName, }; final result = await postUrl( url, body, headers: _headers, ).transform((_) => true); _networkStatusService.finishGalleryLoading(); return result.execErrorCast(); } } class CirclesClient extends FriendicaClient { static final _logger = Logger('$CirclesClient'); CirclesClient(super.credentials) : super(); FutureResult, ExecError> getCircles() async { _logger.finest(() => 'Getting circle (Mastodon List) data'); final url = 'https://$serverName/api/v1/lists'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (listsJson) async => listsJson.data .map((json) => CircleDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult>, ExecError> getCircleMembers( CircleData circleData, PagingData page, ) async { _networkStatusService.startConnectionUpdateStatus(); _logger.finest(() => 'Getting members for circle (Mastodon List) of name ${circleData.name} with paging: $page'); final baseUrl = 'https://$serverName/api/v1/lists/${circleData.id}/accounts'; final url = Uri.parse('$baseUrl?${page.toQueryParameters()}'); final result = await _getApiPagedRequest(url); _networkStatusService.finishConnectionUpdateStatus(); return result .andThenSuccess((response) => response.map((jsonArray) => (jsonArray as List) .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult createCircle(String title) async { _logger.finest(() => 'Creating circle (Mastodon List) of name $title'); final url = 'https://$serverName/api/v1/lists'; final body = { 'title': title, }; final result = await postUrl( Uri.parse(url), body, headers: _headers, ).andThenSuccessAsync((data) async => CircleDataMastodonExtensions.fromJson(jsonDecode(data))); return result.execErrorCast(); } FutureResult renameCircle( String id, String title) async { _logger.finest(() => 'Reanming circle (Mastodon List) to name $title'); final url = 'https://$serverName/api/v1/lists/$id'; final body = { 'title': title, }; final result = await putUrl( Uri.parse(url), body, headers: _headers, ).andThenSuccessAsync((data) async { final json = jsonDecode(data); return CircleDataMastodonExtensions.fromJson(json); }); return result.execErrorCast(); } FutureResult deleteCircle(CircleData circleData) async { _logger.finest( () => 'Reanming circle (Mastodon List) to name ${circleData.name}'); final url = 'https://$serverName/api/v1/lists/${circleData.id}'; final result = await deleteUrl(Uri.parse(url), {}, headers: _headers); return result.mapValue((_) => true).execErrorCast(); } FutureResult, ExecError> getMemberCirclesForConnection( String connectionId) async { _logger.finest(() => 'Getting circles (Mastodon Lists) containing connection: $connectionId'); final url = 'https://$serverName/api/v1/accounts/$connectionId/lists'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (listsJson) async => listsJson.data .map((json) => CircleDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult addConnectionToCircle( CircleData circle, Connection connection, ) async { _logger.finest(() => 'Adding connection to circle'); final url = 'https://$serverName/api/v1/lists/${circle.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await postUrl(request, requestData, headers: _headers)) .mapValue((_) => true); } FutureResult removeConnectionFromCircle( CircleData circle, Connection connection, ) async { _logger.finest(() => 'Adding connection to circle'); final url = 'https://$serverName/api/v1/lists/${circle.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await deleteUrl(request, requestData, headers: _headers)) .mapValue((_) => true); } } class ImageClient extends FriendicaClient { ImageClient(super.credentials) : super(); FutureResult editImageData(ImageEntry image) async { _networkStatusService.startGalleryLoading(); final uri = Uri.parse('https://$serverName/api/friendica/photo/update'); final body = { 'album': image.album, 'desc': image.description, 'photo_id': image.id, }; final result = await postUrl(uri, body, headers: _headers) .andThen((_) => Result.ok(image)); _networkStatusService.finishGalleryLoading(); return result.execErrorCast(); } FutureResult deleteImage(ImageEntry image) async { final uri = Uri.parse( 'https://$serverName/api/friendica/photo/delete?photo_id=${image.id}', ); final result = await postUrl(uri, {}, headers: _headers) .andThen((_) => Result.ok(image)); return result.execErrorCast(); } } class InteractionsClient extends FriendicaClient { static final _logger = Logger('$InteractionsClient'); InteractionsClient(super.credentials) : super(); // TODO Convert getLikes to using paging for real FutureResult, ExecError> getLikes(String id) async { _networkStatusService.startInteractionsLoading(); final result = (await runCatchingAsync(() async { final url = 'https://$serverName/api/v1/statuses/$id/favourited_by'; final request = Uri.parse(url); _logger.finest(() => 'Getting favorites for status $id'); return (await _getApiListRequest(request) .andThenSuccessAsync((jsonArray) async { return jsonArray.data .map((p) => ConnectionMastodonExtensions.fromJson(p)) .toList(); })); })) .execErrorCastAsync(); _networkStatusService.finishInteractionsLoading(); return result; } // TODO Convert getReshares to using paging for real FutureResult, ExecError> getReshares(String id) async { _networkStatusService.startInteractionsLoading(); final result = (await runCatchingAsync(() async { final url = 'https://$serverName/api/v1/statuses/$id/reblogged_by'; final request = Uri.parse(url); _logger.finest(() => 'Getting rebloggers for status $id'); return (await _getApiListRequest(request) .andThenSuccessAsync((jsonArray) async { return jsonArray.data .map((p) => ConnectionMastodonExtensions.fromJson(p)) .toList(); })); })) .execErrorCastAsync(); _networkStatusService.finishInteractionsLoading(); return result; } FutureResult changeFavoriteStatus( 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, {}, headers: _headers); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } } class InstanceDataClient extends FriendicaClient { static final _logger = Logger('$InteractionsClient'); InstanceDataClient(super.credentials); FutureResult getInstanceData() async { _logger.finest(() => 'Getting $serverName instance info'); final v2Result = await getInstanceDataV2(); if (v2Result.isSuccess) { return v2Result; } return getInstanceDataV1(); } FutureResult getInstanceDataV1() async { _logger.finest(() => 'Getting $serverName instance info via V1 endpoint'); final url = Uri.parse('https://$serverName/api/v1/instance'); final result = await _getApiRequest(url); return result.andThen((json) { return fromInstanceV1Json(json); }).execErrorCast(); } FutureResult getInstanceDataV2() async { _logger.finest(() => 'Getting $serverName instance info via V2 endpoint'); final url = Uri.parse('https://$serverName/api/v2/instance'); final result = await _getApiRequest(url); return result.andThen((response) { final instanceInfoResult = fromInstanceV2Json(response); return instanceInfoResult; }).execErrorCast(); } } class NotificationsClient extends FriendicaClient { static final _logger = Logger('$NotificationsClient'); NotificationsClient(super.credentials) : super(); FutureResult>, ExecError> getNotifications(PagingData page, bool includeAll) async { _networkStatusService.startNotificationUpdate(); final url = 'https://$serverName/api/v1/notifications?include_all=$includeAll'; final request = Uri.parse('$url&${page.toQueryParameters()}'); _logger.finest(() => 'Getting new notifications'); final result = await _getApiListRequest(request).transformAsync((response) async { final notifications = []; final st = Stopwatch()..start(); for (final json in response.data) { if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } notifications.add(NotificationMastodonExtension.fromJson(json)); } return PagedResponse(notifications, id: response.id, previous: response.previous, next: response.next); }); _networkStatusService.finishNotificationUpdate(); return result.execErrorCast(); } FutureResult clearNotifications() async { final url = 'https://$serverName/api/v1/notifications/clear'; final request = Uri.parse(url); _logger.finest(() => 'Clearing unread notification'); final response = await postUrl(request, {}, headers: _headers); return response.mapValue((value) => true); } FutureResult clearNotification( UserNotification notification) async { final url = 'https://$serverName/api/v1/notifications/${notification.id}/dismiss'; final request = Uri.parse(url); _logger.fine(() => 'Clearing unread notification for $notification'); final response = await postUrl(request, {}, headers: _headers); return response.mapValue((value) => true); } } class RelationshipsClient extends FriendicaClient { static final _logger = Logger('$RelationshipsClient'); RelationshipsClient(super.credentials) : super(); FutureResult>, ExecError> getBlocks( PagingData page) async { _networkStatusService.startNotificationUpdate(); final url = 'https://$serverName/api/v1/blocks'; final request = Uri.parse('$url&${page.toQueryParameters()}'); _logger.finest(() => 'Getting blocks for $page'); final result = await _getApiListRequest(request).transformAsync((response) async { final blocks = []; final st = Stopwatch()..start(); for (final json in response.data) { if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } blocks.add( ConnectionMastodonExtensions.fromJson(json) .copy(status: ConnectionStatus.blocked), ); } return PagedResponse(blocks, id: response.id, previous: response.previous, next: response.next); }); _networkStatusService.finishNotificationUpdate(); return result.execErrorCast(); } FutureResult>, ExecError> getMyFollowing( PagingData page) async { _logger.fine(() => 'Getting following with paging data $page'); _networkStatusService.startConnectionUpdateStatus(); final myId = profile.userId; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final result = await _getApiListRequest( Uri.parse('$baseUrl/following?${page.toQueryParameters()}'), ); _networkStatusService.finishConnectionUpdateStatus(); return result .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult>, ExecError> getFollowRequests( PagingData page) async { _logger.finest(() => 'Getting follow requests with paging data $page'); _networkStatusService.startConnectionUpdateStatus(); final baseUrl = 'https://$serverName/api/v1/follow_requests'; final result = await _getApiListRequest( Uri.parse('$baseUrl?${page.toQueryParameters()}'), ); _networkStatusService.finishConnectionUpdateStatus(); return result .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => FollowRequestMastodonExtension.fromJson(json)) .toList())) .execErrorCast(); } FutureResult>, ExecError> getMyFollowers( PagingData page) async { _logger.finest(() => 'Getting followers data with page data $page'); _networkStatusService.startConnectionUpdateStatus(); final myId = profile.userId; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final result1 = await _getApiListRequest( Uri.parse('$baseUrl/followers?${page.toQueryParameters()}'), ); _networkStatusService.finishConnectionUpdateStatus(); return result1 .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult getConnectionWithStatus( Connection connection) async { _logger.finest(() => 'Getting circle (Mastodon List) data'); _networkStatusService.startConnectionUpdateStatus(); final myId = profile.userId; final id = int.parse(connection.id); final paging = '?min_id=${id - 1}&max_id=${id + 1}'; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final following = await _getApiListRequest(Uri.parse('$baseUrl/following$paging')).fold( onSuccess: (followings) => followings.data.isNotEmpty, onError: (error) { _logger.severe('Error getting following list: $error'); return false; }); final follower = await _getApiListRequest(Uri.parse('$baseUrl/followers$paging')).fold( onSuccess: (followings) => followings.data.isNotEmpty, onError: (error) { _logger.severe('Error getting follower list: $error'); return false; }); var status = ConnectionStatus.none; if (following && follower) { status = ConnectionStatus.mutual; } else if (following) { status = ConnectionStatus.youFollowThem; } else if (follower) { status = ConnectionStatus.theyFollowYou; } _networkStatusService.finishConnectionUpdateStatus(); return Result.ok(connection.copy(status: status)); } FutureResult>, ExecError> getConnectionRequests(PagingData page) async { _logger.finest(() => 'Getting connection requests with page data $page'); _networkStatusService.startConnectionUpdateStatus(); final baseUrl = 'https://$serverName/api/v1/follow_requests'; final result1 = await _getApiListRequest( Uri.parse('$baseUrl&${page.toQueryParameters()}'), ); _networkStatusService.finishConnectionUpdateStatus(); return result1 .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult acceptFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize'); final result = await postUrl(url, {}, headers: _headers) .andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult rejectFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject'); final result = await postUrl(url, {}, headers: _headers) .andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult ignoreFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore'); final result = await postUrl(url, {}, headers: _headers) .andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult blockConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/block'); final result = await postUrl(url, {}, headers: _headers).transform( (_) => connection.copy(status: ConnectionStatus.blocked), ); return result.execErrorCast(); } FutureResult unblockConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unblock'); final result = await postUrl(url, {}, headers: _headers) .transformAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.execErrorCast(); } FutureResult followConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow'); final result = await postUrl(url, {}, headers: _headers).transform((jsonString) { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.execErrorCast(); } FutureResult unFollowConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow'); final result = await postUrl(url, {}, headers: _headers).transform((jsonString) { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.execErrorCast(); } Connection _updateConnectionFromFollowRequestResult( Connection connection, String jsonString) { final json = jsonDecode(jsonString) as Map; final theyFollowYou = json['followed_by'] ?? 'false'; final youFollowThem = json['following'] ?? 'false'; late final ConnectionStatus newStatus; if (theyFollowYou && youFollowThem) { newStatus = ConnectionStatus.mutual; } else if (theyFollowYou) { newStatus = ConnectionStatus.theyFollowYou; } else if (youFollowThem) { newStatus = ConnectionStatus.youFollowThem; } else { newStatus = ConnectionStatus.none; } return connection.copy(status: newStatus); } } class RemoteFileClient extends FriendicaClient { static final _logger = Logger('$RemoteFileClient'); RemoteFileClient(super.credentials) : super(); FutureResult getFileBytes(Uri url) async { _logger.finest('GET: $url'); try { final response = await http.get( url, headers: { 'Authorization': _profile.credentials.authHeaderValue, }, ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(response.bodyBytes); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult uploadFileAsAttachment({ required List bytes, String description = '', String album = '', String fileName = '', required Visibility visibility, }) async { final postUri = Uri.parse('https://$serverName/api/friendica/photo/create'); final request = http.MultipartRequest('POST', postUri); request.headers['Authorization'] = _profile.credentials.authHeaderValue; if (usePhpDebugging) { request.headers['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/'; } request.fields['desc'] = description; request.fields['album'] = album; request.fields.addAll(visibility.toMapEntries()); request.files.add(http.MultipartFile.fromBytes( 'media', filename: fileName, contentType: MediaType.parse('image/${ImageTypes.fromExtension(fileName).name}'), bytes)); final response = await request.send(); final body = utf8.decode(await response.stream.toBytes()); if (response.statusCode != 200) { return Result.error( ExecError( type: ErrorType.missingEndpoint, message: body, ), ); } final imageDataJson = jsonDecode(body); final newImageData = ImageEntryFriendicaExtension.fromJson(imageDataJson); return Result.ok(newImageData); } } class ProfileClient extends FriendicaClient { static final _logger = Logger('$ProfileClient'); ProfileClient(super.credentials) : super(); FutureResult getMyProfile() async { _logger.finest(() => 'Getting logged in user profile'); final request = Uri.parse('https://$serverName/api/v1/accounts/verify_credentials'); return (await _getApiRequest(request, timeout: oauthTimeout)) .mapValue((json) => ConnectionMastodonExtensions.fromJson( json, defaultServerName: serverName, ).copy( status: ConnectionStatus.you, network: 'friendica', )); } } class StatusesClient extends FriendicaClient { static final _logger = Logger('$StatusesClient'); StatusesClient(super.credentials) : super(); // TODO Convert getPostOrComment to using paging for real FutureResult, ExecError> getPostOrComment(String id, {bool fullContext = false}) async { _networkStatusService.startTimelineLoading(); final result = (await runCatchingAsync(() async { final baseUrl = 'https://$serverName/api/v1/statuses/$id'; final url = fullContext ? '$baseUrl/context' : baseUrl; final request = Uri.parse('$url?limit=1000'); _logger.finest(() => 'Getting entry for status $id, full context? $fullContext : $url'); return (await _getApiRequest(request).andThenSuccessAsync((json) async { if (fullContext) { final ancestors = json['ancestors'] as List; final descendants = json['descendants'] as List; final items = [ ...ancestors .map((a) => TimelineEntryMastodonExtensions.fromJson(a)), ...descendants .map((d) => TimelineEntryMastodonExtensions.fromJson(d)) ]; return items; } else { return [TimelineEntryMastodonExtensions.fromJson(json)]; } })); })); _networkStatusService.finishTimelineLoading(); return result.execErrorCast(); } FutureResult createNewStatus({ required String text, String spoilerText = '', String inReplyToId = '', List mediaIds = const [], required Visibility visibility, }) async { _logger.finest(() => 'Creating status ${inReplyToId.isNotEmpty ? "In Reply to: " : ""} $inReplyToId, with media: $mediaIds'); final url = Uri.parse('https://$serverName/api/v1/statuses'); final body = { 'status': text, if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId, if (mediaIds.isNotEmpty) 'media_ids': mediaIds, 'visibility': visibility.toCreateStatusValue(), 'friendica': { 'title': '', }, }; final result = await postUrl(url, body, headers: _headers); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult editStatus({ required String id, required String text, String spoilerText = '', List mediaIds = const [], }) async { _logger.finest(() => 'Updating status $id'); final url = Uri.parse('https://$serverName/api/v1/statuses/$id'); final body = { 'status': text, if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (mediaIds.isNotEmpty) 'media_ids': mediaIds, 'friendica': { 'title': '', }, }; final result = await putUrl(url, body, headers: _headers); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult 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, {}, headers: _headers); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult 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, {}, headers: _headers); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult deleteEntryById(String id) async { _logger.finest(() => 'Deleting post/comment $id'); final url = 'https://$serverName/api/v1/statuses/$id'; final request = Uri.parse(url); return (await deleteUrl( request, {}, headers: _headers, )) .mapValue((_) => true); } } class SearchClient extends FriendicaClient { static final _logger = Logger('$StatusesClient'); SearchClient(super.credentials) : super(); FutureResult, ExecError> search( SearchTypes type, String searchTerm, PagingData page) async { _logger.finest(() => 'Searching $type for term: $searchTerm'); if (type == SearchTypes.directLink) { final isFediverseResult = await getIt() .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(); } } class TimelineClient extends FriendicaClient { static final _logger = Logger('$TimelineClient'); TimelineClient(super.credentials) : super(); // TODO Convert User Timeline to using paging for real FutureResult, ExecError> getUserTimeline( {String userId = '', int page = 1, int count = 10}) async { _logger.finest(() => 'Getting user timeline for $userId'); _networkStatusService.startTimelineLoading(); final baseUrl = 'https://$serverName/api/statuses/user_timeline?'; final pagingData = 'count=$count&page=$page'; final url = userId.isEmpty ? '$baseUrl$pagingData' : '${baseUrl}screen_name=$userId$pagingData'; final request = Uri.parse(url); final result = await _getApiListRequest(request).transformAsync( (postsJson) async => await _timelineEntriesFromJson(postsJson.data), ); _networkStatusService.finishTimelineLoading(); return result.execErrorCast(); } FutureResult, ExecError> getTimeline( {required TimelineIdentifiers type, required PagingData page}) async { _networkStatusService.startTimelineLoading(); final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); final baseUrl = 'https://$serverName/api/v1/$timelinePath'; final url = '$baseUrl?exclude_replies=true&${page.toQueryParameters()}&$timelineQPs'; final request = Uri.parse(url); _logger.finest(() => 'Getting ${type.toHumanKey()} with paging data $page'); final result = await _getApiListRequest(request).transformAsync( (postsJson) async => await _timelineEntriesFromJson(postsJson.data), ); _networkStatusService.finishTimelineLoading(); return result.execErrorCast(); } Future> _timelineEntriesFromJson( List postsJson, ) async { final finalResult = []; final st = Stopwatch()..start(); for (final json in postsJson) { if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } finalResult.add(TimelineEntryMastodonExtensions.fromJson(json)); } return finalResult; } String _typeToTimelinePath(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: return 'timelines/home'; case TimelineType.global: return 'timelines/public'; case TimelineType.local: return 'timelines/public'; case TimelineType.circle: return 'timelines/list/${type.auxData}'; case TimelineType.profile: return '/accounts/${type.auxData}/statuses'; case TimelineType.self: final myId = profile.userId; return '/accounts/$myId/statuses'; } } String _typeToTimelineQueryParameters(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: case TimelineType.global: case TimelineType.profile: case TimelineType.circle: case TimelineType.self: return ''; case TimelineType.local: return 'local=true'; } } } abstract class FriendicaClient { final Profile _profile; late final NetworkStatusService _networkStatusService; String get serverName => _profile.serverName; Profile get profile => _profile; FriendicaClient(Profile credentials) : _profile = credentials { _networkStatusService = getIt(); } FutureResult>, ExecError> _getApiListRequest( Uri url, { Duration? timeout, }) async { return await getUrl( url, headers: _headers, timeout: timeout, ).transformAsync( (response) async { return response.map((data) => jsonDecode(data) as List); }, ).execErrorCastAsync(); } FutureResult, ExecError> _getApiPagedRequest(Uri url, {Duration? timeout}) async { return await getUrl( url, headers: _headers, timeout: timeout, ).transformAsync( (response) async { return response.map((data) => jsonDecode(data)); }, ).execErrorCastAsync(); } FutureResult _getApiRequest(Uri url, {Duration? timeout}) async { return await getUrl( url, headers: _headers, timeout: timeout, ).transformAsync( (response) async { return jsonDecode(response.data); }, ).execErrorCastAsync(); } Map get _headers => { 'Authorization': _profile.credentials.authHeaderValue, 'Content-Type': 'application/json; charset=UTF-8', }; }