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/connection.dart'; import '../models/credentials.dart'; import '../models/direct_message.dart'; import '../models/exec_error.dart'; import '../models/gallery_data.dart'; import '../models/group_data.dart'; import '../models/image_entry.dart'; import '../models/media_attachment_uploads/image_types_enum.dart'; import '../models/timeline_entry.dart'; import '../models/user_notification.dart'; import '../serializers/friendica/connection_friendica_extensions.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/mastodon/connection_mastodon_extensions.dart'; import '../serializers/mastodon/group_data_mastodon_extensions.dart'; import '../serializers/mastodon/notification_mastodon_extension.dart'; import '../serializers/mastodon/timeline_entry_mastodon_extensions.dart'; import '../services/auth_service.dart'; import '../services/network_status_service.dart'; import 'paging_data.dart'; class FriendicaClient { static final _logger = Logger('$FriendicaClient'); final Credentials _credentials; late final String _authHeader; late final NetworkStatusService _networkStatusService; String get serverName => _credentials.serverName; Credentials get credentials => _credentials; FriendicaClient({required Credentials credentials}) : _credentials = credentials { final authenticationString = "${_credentials.username}:${_credentials.password}"; final encodedAuthString = base64Encode(utf8.encode(authenticationString)); _authHeader = "Basic $encodedAuthString"; _networkStatusService = getIt(); } // TODO Convert Notifications to using paging for real FutureResult, ExecError> getNotifications() async { _networkStatusService.startNotificationUpdate(); final url = 'https://$serverName/api/v1/notifications?include_all=true&limit=200'; final request = Uri.parse(url); _logger.finest(() => 'Getting new notifications'); final result = (await _getApiListRequest(request).andThenSuccessAsync( (notificationsJson) async => notificationsJson.data .map((json) => NotificationMastodonExtension.fromJson(json)) .toList())) .mapError((error) { if (error is ExecError) { return error; } _networkStatusService.finishNotificationUpdate(); return ExecError(type: ErrorType.localError, message: error.toString()); }); _networkStatusService.finishNotificationUpdate(); return result; } 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, {}); 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.finest(() => 'Clearing unread notification for $notification'); final response = await _postUrl(request, {}); return response.mapValue((value) => true); } // 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; } // TODO Convert Groups to using paging for real (if it is supported) FutureResult, ExecError> getGroups() async { _logger.finest(() => 'Getting group (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) => GroupDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult addConnectionToGroup( GroupData group, Connection connection, ) async { _logger.finest(() => 'Adding connection to group'); final url = 'https://$serverName/api/v1/lists/${group.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await _postUrl(request, requestData)).mapValue((_) => true); } FutureResult removeConnectionFromGroup( GroupData group, Connection connection, ) async { _logger.finest(() => 'Adding connection to group'); final url = 'https://$serverName/api/v1/lists/${group.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await _deleteUrl(request, requestData)).mapValue((_) => true); } FutureResult>, ExecError> getMyFollowing( PagingData page) async { _logger.finest(() => 'Getting following with paging data $page'); final myId = getIt().currentId; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final result = await _getApiListRequest( Uri.parse('$baseUrl/following?${page.toQueryParameters()}'), ); return result .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult>, ExecError> getMyFollowers( PagingData page) async { _logger.finest(() => 'Getting followers data with page data $page'); final myId = getIt().currentId; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final result1 = await _getApiListRequest( Uri.parse('$baseUrl/followers&${page.toQueryParameters()}'), ); return result1 .andThenSuccess((response) => response.map((jsonArray) => jsonArray .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult getConnectionWithStatus( Connection connection) async { _logger.finest(() => 'Getting group (Mastodon List) data'); final myId = getIt().currentId; 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; } return Result.ok(connection.copy(status: status)); } // TODO Convert groups for connection to using paging for real (if available) FutureResult, ExecError> getMemberGroupsForConnection( String connectionId) async { _logger.finest(() => 'Getting groups (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) => GroupDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } // 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).andThenSuccessAsync( (postsJson) async => postsJson.data .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); _networkStatusService.finishTimelineLoading(); return result; } 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).andThenSuccessAsync( (postsJson) async => postsJson.data .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); _networkStatusService.finishTimelineLoading(); return result; } FutureResult getFileBytes(Uri url) async { _logger.finest('GET: $url'); try { final response = await http.get( url, headers: { 'Authorization': _authHeader, }, ); 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())); } } // TODO Convert getPostOrComment to using paging for real FutureResult, ExecError> getPostOrComment(String id, {bool fullContext = false}) async { return (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)]; } })); })) .execErrorCastAsync(); } // 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 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, {})).mapValue((_) => true); } FutureResult createNewStatus({ required String text, String spoilerText = '', String inReplyToId = '', List mediaIds = const [], }) 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, 'visibility': 'public', if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId, if (mediaIds.isNotEmpty) 'media_ids': mediaIds, }; final result = await _postUrl(url, body); 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, {}); 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, {}); 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 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, {}); 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 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, {}).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, {}).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, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } 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, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } 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, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult getMyProfile() async { _logger.finest(() => 'Getting logged in user profile'); final request = Uri.parse('https://$serverName/api/friendica/profile/show'); return (await _getApiRequest(request)).mapValue((json) => ConnectionFriendicaExtensions.fromJson(json['friendica_owner']) .copy(status: ConnectionStatus.you, network: 'friendica')); } FutureResult uploadFileAsAttachment({ required List bytes, String description = '', String album = '', String fileName = '', }) async { final postUri = Uri.parse('https://$serverName/api/friendica/photo/create'); final request = http.MultipartRequest('POST', postUri); request.headers['Authorization'] = _authHeader; request.fields['desc'] = description; request.fields['album'] = album; request.files.add(await 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); } FutureResult, ExecError> getDirectMessages( PagingData page) async { 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'); return (await _getApiListRequest(request).andThenSuccessAsync( (response) async => response.data .map((json) => DirectMessageFriendicaExtension.fromJson(json)) .toList())) .execErrorCast(); } FutureResult markDirectMessageRead( DirectMessage message) async { final id = message.id; final url = Uri.parse( 'https://$serverName/api/friendica/direct_messages_setseen?id=$id'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return message.copy(seen: true); }); return result.execErrorCast(); } FutureResult postDirectMessage( String? messageIdRepliedTo, String receivingUserId, String text, ) async { 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) .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))); }); return result.execErrorCast(); } FutureResult, ExecError> _getUrl(Uri url) async { _logger.finer('GET: $url'); try { final response = await http.get( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, ); 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 _postUrl( Uri url, Map body) async { _logger.finer('POST: $url \n Body: $body'); try { final response = await http.post( url, headers: { 'Cookies': 'XDEBUG_SESSION=PHPSTORM;path=/;', 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, 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 _deleteUrl( Uri url, Map body) async { _logger.finer('DELETE: $url'); try { final response = await http.delete( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, 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>, ExecError> _getApiListRequest( Uri url) async { return (await _getUrl(url).andThenSuccessAsync( (response) async => response.map((data) => jsonDecode(data) as List), )) .mapError((error) => error as ExecError); } FutureResult _getApiRequest(Uri url) async { return (await _getUrl(url).andThenSuccessAsync( (response) async => jsonDecode(response.data), )) .execErrorCastAsync(); } 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.group: return 'timelines/list/${type.auxData}'; case TimelineType.profile: return '/accounts/${type.auxData}/statuses'; case TimelineType.self: final myId = getIt().currentId; return '/accounts/$myId/statuses'; } } String _typeToTimelineQueryParameters(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: case TimelineType.global: case TimelineType.profile: case TimelineType.group: case TimelineType.self: return ''; case TimelineType.local: return 'local=true'; } } 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); } }