import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import 'models/TimelineIdentifiers.dart'; import 'models/connection.dart'; import 'models/credentials.dart'; import 'models/exec_error.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/connection_friendica_extensions.dart'; import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; class FriendicaClient { static final _logger = Logger('$FriendicaClient'); final Credentials _credentials; late final String _authHeader; 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"; } FutureResult, ExecError> getNotifications() async { final url = 'https://$serverName/api/v1/notifications?include_all=true'; final request = Uri.parse(url); _logger.finest(() => 'Getting new notifications'); return (await _getApiListRequest(request).andThenSuccessAsync( (notificationsJson) async => notificationsJson .map((json) => NotificationMastodonExtension.fromJson(json)) .toList())) .mapError((error) { if (error is ExecError) { return error; } return ExecError(type: ErrorType.localError, message: error.toString()); }); } 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); } FutureResult, ExecError> getUserTimeline( {String userId = '', int page = 1, int count = 10}) async { _logger.finest(() => 'Getting user timeline for $userId'); 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); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult, ExecError> getTimeline( {required TimelineIdentifiers type, int sinceId = 0, int maxId = 0, int limit = 20}) async { final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); final baseUrl = 'https://$serverName/api/v1/$timelinePath'; var pagingData = 'limit=$limit'; if (maxId > 0) { pagingData = '$pagingData&max_id=$maxId'; } if (sinceId > 0) { pagingData = '&since_id=$sinceId'; } final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs'; final request = Uri.parse(url); _logger.finest(() => 'Getting ${type.toHumanKey()} limit $limit sinceId: $sinceId maxId: $maxId : $url'); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } 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); _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)]; } })); })) .mapError((error) => ExecError( type: ErrorType.parsingError, message: error.toString(), )); } FutureResult createNewStatus( {required String text, String spoilerText = '', String inReplyToId = ''}) async { _logger.finest(() => 'Creating status ${inReplyToId.isNotEmpty ? "In Reply to: " : ""} $inReplyToId'); 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, }; 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 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 _getUrl(Uri url) async { _logger.finest('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 Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult _postUrl( Uri url, Map body) async { _logger.finest('POST: $url'); try { final response = await http.post( 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(response.body); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult, ExecError> _getApiListRequest(Uri url) async { return (await _getUrl(url).andThenSuccessAsync( (jsonText) async => jsonDecode(jsonText) as List)) .mapError((error) => error as ExecError); } FutureResult _getApiRequest(Uri url) async { return (await _getUrl(url) .andThenSuccessAsync((jsonText) async => jsonDecode(jsonText))) .mapError((error) => error as ExecError); } 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.tag: case TimelineType.profile: return '/accounts/${type.auxData}/statuses'; case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } } String _typeToTimelineQueryParameters(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: case TimelineType.global: case TimelineType.profile: return ''; case TimelineType.local: return 'local=true'; case TimelineType.tag: case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } } }