From b13b2d99109cfabd06e8213da4007328b7494052 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Sat, 17 Jan 2026 10:46:58 -0800 Subject: [PATCH] refactor: improve api layer architecture to support additional api versions --- lib/src/core/network/api_client_factory.dart | 95 ++ lib/src/core/network/api_exception.dart | 111 ++ lib/src/core/network/base_api_client.dart | 162 +++ .../network/lemmy/base_lemmy_api_client.dart | 1071 +++++++++++++++++ .../network/lemmy/lemmy_v3_api_client.dart | 61 + .../network/lemmy/lemmy_v4_api_client.dart | 95 ++ lib/src/core/network/lemmy_api.dart | 781 ------------ .../network/piefed/piefed_api_client.dart | 811 +++++++++++++ lib/src/core/network/piefed_api.dart | 648 ---------- lib/src/core/network/thunder_api_client.dart | 462 +++++++ .../repositories/account_repository_impl.dart | 162 ++- .../repositories/account_repository.dart | 6 + .../presentation/bloc/profile_bloc.dart | 2 +- .../repositories/comment_repository_impl.dart | 203 +--- .../bloc/create_comment_cubit.dart | 24 +- .../community_repository_impl.dart | 134 +-- .../bloc/anonymous_subscriptions_bloc.dart | 8 +- .../feed/presentation/bloc/feed_bloc.dart | 3 +- .../repositories/instance_repository.dart | 68 +- .../data/repositories/modlog_repository.dart | 280 +---- .../presentation/bloc/modlog_cubit.dart | 5 +- .../presentation/pages/modlog_page.dart | 37 +- .../repositories/notification_repository.dart | 164 +-- .../data/repositories/post_repository.dart | 345 ++---- .../presentation/cubit/create_post_cubit.dart | 24 +- .../data/repositories/search_repository.dart | 169 +-- .../search/presentation/bloc/search_bloc.dart | 7 +- .../data/repositories/user_repository.dart | 72 +- .../presentation/bloc/user_settings_bloc.dart | 5 +- lib/src/shared/utils/error_messages.dart | 4 +- 30 files changed, 3382 insertions(+), 2637 deletions(-) create mode 100644 lib/src/core/network/api_client_factory.dart create mode 100644 lib/src/core/network/api_exception.dart create mode 100644 lib/src/core/network/base_api_client.dart create mode 100644 lib/src/core/network/lemmy/base_lemmy_api_client.dart create mode 100644 lib/src/core/network/lemmy/lemmy_v3_api_client.dart create mode 100644 lib/src/core/network/lemmy/lemmy_v4_api_client.dart delete mode 100644 lib/src/core/network/lemmy_api.dart create mode 100644 lib/src/core/network/piefed/piefed_api_client.dart delete mode 100644 lib/src/core/network/piefed_api.dart create mode 100644 lib/src/core/network/thunder_api_client.dart diff --git a/lib/src/core/network/api_client_factory.dart b/lib/src/core/network/api_client_factory.dart new file mode 100644 index 000000000..2b047ac61 --- /dev/null +++ b/lib/src/core/network/api_client_factory.dart @@ -0,0 +1,95 @@ +import 'package:http/http.dart' as http; +import 'package:version/version.dart'; + +import 'package:thunder/src/core/cache/platform_version_cache.dart'; +import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/network/lemmy/lemmy_v3_api_client.dart'; +import 'package:thunder/src/core/network/lemmy/lemmy_v4_api_client.dart'; +import 'package:thunder/src/core/network/piefed/piefed_api_client.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/account/account.dart'; + +/// Factory for creating the appropriate API client based on platform and version. +/// +/// Example usage: +/// ```dart +/// final api = ApiClientFactory.create(account); +/// final posts = await api.getPosts(); +/// ``` +class ApiClientFactory { + /// Create a new API client for the given account. + /// + /// The factory will automatically select the appropriate client based on: + /// - The account's platform (Lemmy, PieFed, etc.) + /// - The instance's API version (from PlatformVersionCache) + static ThunderApiClient create( + Account account, { + bool debug = false, + http.Client? httpClient, + }) { + return switch (account.platform) { + ThreadiversePlatform.lemmy => _createLemmyClient(account, debug, httpClient), + ThreadiversePlatform.piefed => _createPiefedClient(account, debug, httpClient), + _ => throw UnsupportedError('Unsupported platform: ${account.platform}'), + }; + } + + /// Create the appropriate Lemmy client based on version. + static ThunderApiClient _createLemmyClient( + Account account, + bool debug, + http.Client? httpClient, + ) { + final version = PlatformVersionCache().get(account.instance); + + // Check if the instance requires v4 API (Lemmy 1.0.0+) + if (version != null && _isLemmyApiV4(version)) { + return LemmyV4ApiClient( + account: account, + debug: debug, + version: version, + httpClient: httpClient, + ); + } + + // Default to v3 API for older instances or when version is unknown + return LemmyV3ApiClient( + account: account, + debug: debug, + version: version, + httpClient: httpClient, + ); + } + + /// Create a PieFed client. + static ThunderApiClient _createPiefedClient( + Account account, + bool debug, + http.Client? httpClient, + ) { + final version = PlatformVersionCache().get(account.instance); + + return PiefedApiClient( + account: account, + debug: debug, + version: version, + httpClient: httpClient, + ); + } + + /// Check if the Lemmy version requires the v4 API. + /// + /// Lemmy 1.0.0+ uses the v4 API. Pre-releases (alpha, beta, rc) of 1.0.0 should also use v4. + static bool _isLemmyApiV4(Version version) { + return version.major >= 1; + } + + /// Create a mock client for testing. + static ThunderApiClient createForTesting({ + required Account account, + required http.Client mockHttpClient, + bool debug = false, + }) { + return create(account, debug: debug, httpClient: mockHttpClient); + } +} diff --git a/lib/src/core/network/api_exception.dart b/lib/src/core/network/api_exception.dart new file mode 100644 index 000000000..c781fe6d6 --- /dev/null +++ b/lib/src/core/network/api_exception.dart @@ -0,0 +1,111 @@ +/// Handles all API errors and exceptions. +library; + +/// Base exception for all API errors. +sealed class ApiException implements Exception { + /// The error message. + String get message; + + /// The error code returned by the API. + String? get errorCode; + + /// The HTTP status code of the response. + int? get statusCode; + + /// The platform name (e.g., 'Lemmy', 'PieFed') for error context. + String? get platformName; +} + +/// Network/HTTP-level exception for connection failures, timeouts, etc. +class NetworkException implements ApiException { + @override + final String message; + + @override + final int? statusCode; + + @override + final String? platformName; + + NetworkException(this.message, {this.statusCode, this.platformName}); + + @override + String? get errorCode => statusCode?.toString(); + + @override + String toString() => '${platformName ?? 'Network'} Error: $message'; +} + +/// API returned an error response (e.g., 4xx, 5xx status codes). +class ApiErrorException implements ApiException { + @override + final String message; + + @override + final String? errorCode; + + @override + final int? statusCode; + + @override + final String? platformName; + + ApiErrorException( + this.message, { + this.errorCode, + this.statusCode, + this.platformName, + }); + + @override + String toString() => '${platformName ?? 'API'} Error [$statusCode]: $message'; +} + +/// Feature not supported on the current platform. +class UnsupportedFeatureException implements ApiException { + /// The feature that is not supported. + final String feature; + + @override + final String? platformName; + + UnsupportedFeatureException(this.feature, {this.platformName}); + + @override + String get message => '$feature is not supported${platformName != null ? ' on $platformName' : ''}'; + + @override + String? get errorCode => 'unsupported_feature'; + + @override + int? get statusCode => null; + + @override + String toString() => message; +} + +/// Rate limit exceeded. +class RateLimitException implements ApiException { + @override + final String message; + + /// Duration to wait before retrying, if provided by the server. + final Duration? retryAfter; + + @override + final String? platformName; + + RateLimitException(this.message, {this.retryAfter, this.platformName}); + + @override + String? get errorCode => 'rate_limit_error'; + + @override + int? get statusCode => 429; + + @override + String toString() { + final waitMessage = retryAfter != null ? ' Retry after ${retryAfter!.inSeconds}s.' : ''; + return '${platformName ?? 'API'} Rate Limit: $message$waitMessage'; + } +} diff --git a/lib/src/core/network/base_api_client.dart b/lib/src/core/network/base_api_client.dart new file mode 100644 index 000000000..dc42ea5a7 --- /dev/null +++ b/lib/src/core/network/base_api_client.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:version/version.dart'; + +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/update/check_github_update.dart'; +import 'package:thunder/src/features/account/account.dart'; + +/// HTTP methods supported by the API. +enum HttpMethod { get, post, put, delete } + +/// Base class containing shared HTTP infrastructure for all API clients. +/// Handles requests, responses, and errors for all API clients. +/// +/// Subclasses must implement [basePath], [platformName], and [uploadImage]. +abstract class BaseApiClient { + /// The account to use for API calls (contains instance URL and JWT). + final Account account; + + /// Whether to show debug information. + final bool debug; + + /// The version of the platform (used for version-specific behavior). + final Version? version; + + /// HTTP client for making requests. Can be injected for testing. + final http.Client httpClient; + + /// Creates a new API client. + /// + /// An optional [httpClient] can be provided for testing purposes. + BaseApiClient({ + required this.account, + this.debug = false, + required this.version, + http.Client? httpClient, + }) : httpClient = httpClient ?? http.Client(); + + /// The base path for API endpoints (e.g., '/api/v3', '/api/alpha'). + String get basePath; + + /// Platform identifier for logging and error messages. + String get platformName; + + /// Build headers with optional JWT authorization. + @protected + Map buildHeaders() { + final appVersion = getCurrentVersion(removeInternalBuildNumber: true, trimV: true); + + return { + 'User-Agent': 'Thunder/$appVersion', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (account.jwt != null) 'Authorization': 'Bearer ${account.jwt}', + }; + } + + /// Handle response from the request. + /// + /// Throws appropriate exceptions for error responses: + /// - [RateLimitException] for 429 status + /// - [ApiErrorException] for other non-200 responses + @protected + Future handleResponse(Uri uri, http.Response response) async { + // Check for rate limiting + if (response.statusCode == 429) { + throw RateLimitException( + 'Rate limit exceeded', + platformName: platformName, + retryAfter: _parseRetryAfter(response.headers['retry-after']), + ); + } + + // Check for error responses + if (response.statusCode != 200) { + if (debug) debugPrint('$platformName API: Failed request to $uri: ${response.statusCode}'); + + // Try to parse error code from response body + String? errorCode; + + try { + final json = jsonDecode(response.body); + errorCode = json['error'] as String? ?? json['error_code'] as String?; + } catch (_) { + // Body is not JSON or doesn't contain error field + } + + throw ApiErrorException( + response.body, + statusCode: response.statusCode, + errorCode: errorCode ?? response.statusCode.toString(), + platformName: platformName, + ); + } + + return compute(jsonDecode, response.body); + } + + /// Parse the Retry-After header value to a Duration. + Duration? _parseRetryAfter(String? value) { + if (value == null) return null; + final seconds = int.tryParse(value); + return seconds != null ? Duration(seconds: seconds) : null; + } + + /// Makes an HTTP request with the specified method. + /// + /// [method] - The HTTP method to use. + /// [endpoint] - The API endpoint (should include [basePath]). + /// [data] - Request parameters/body data. + /// + /// Returns the parsed JSON response body. Throws [ApiException] subclasses on errors. + Future> request( + HttpMethod method, + String endpoint, + Map data, + ) async { + try { + final headers = buildHeaders(); + data.removeWhere((key, value) => value == null); + + Uri uri; + http.Response response; + + switch (method) { + case HttpMethod.get: + // Convert all values to strings for query parameters + final queryParams = data.map((k, v) => MapEntry(k, v.toString())); + uri = Uri.https(account.instance, endpoint, queryParams.isEmpty ? null : queryParams); + if (debug) debugPrint('$platformName API: GET $uri'); + response = await httpClient.get(uri, headers: headers); + + case HttpMethod.post: + uri = Uri.https(account.instance, endpoint); + if (debug) debugPrint('$platformName API: POST $uri'); + response = await httpClient.post(uri, body: jsonEncode(data), headers: headers); + + case HttpMethod.put: + uri = Uri.https(account.instance, endpoint); + if (debug) debugPrint('$platformName API: PUT $uri'); + response = await httpClient.put(uri, body: jsonEncode(data), headers: headers); + + case HttpMethod.delete: + uri = Uri.https(account.instance, endpoint); + if (debug) debugPrint('$platformName API: DELETE $uri'); + response = await httpClient.delete(uri, headers: headers); + } + + return await handleResponse(uri, response) as Map; + } catch (e) { + if (debug) debugPrint('$platformName API: Error: $e'); + rethrow; + } + } + + /// Clean up resources. Should be called when the client is no longer needed. + void dispose() { + httpClient.close(); + } +} diff --git a/lib/src/core/network/lemmy/base_lemmy_api_client.dart b/lib/src/core/network/lemmy/base_lemmy_api_client.dart new file mode 100644 index 000000000..e00835a51 --- /dev/null +++ b/lib/src/core/network/lemmy/base_lemmy_api_client.dart @@ -0,0 +1,1071 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/core/enums/feed_list_type.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/core/models/thunder_comment_report.dart'; +import 'package:thunder/src/core/models/thunder_post_report.dart'; +import 'package:thunder/src/core/models/thunder_private_message.dart'; +import 'package:thunder/src/core/models/thunder_site.dart'; +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/base_api_client.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Base class for Lemmy API clients, containing shared logic between v3 and v4. +abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiClient { + BaseLemmyApiClient({ + required super.account, + super.debug, + required super.version, + super.httpClient, + }); + + @override + String get platformName => 'Lemmy'; + + // ============================================================= + // Abstract methods for version-specific parsing + // ============================================================= + + /// Parse a post from raw JSON (version-specific). + ThunderPost parsePost(Map json); + + /// Parse a comment from raw JSON (version-specific). + ThunderComment parseComment(Map json); + + /// Parse a user/person from raw JSON (version-specific). + ThunderUser parseUser(Map json); + + /// Parse a user view from raw JSON (version-specific). + ThunderUser parseUserView(Map json); + + /// Parse a community from raw JSON (version-specific). + ThunderCommunity parseCommunity(Map json); + + /// Parse a community view from raw JSON (version-specific). + ThunderCommunity parseCommunityView(Map json); + + /// Parse a site response from raw JSON (version-specific). + ThunderSiteResponse parseSiteResponse(Map json); + + // ============================================================= + // Authentication & Site + // ============================================================= + + @override + Future login({required String username, required String password, String? totp}) async { + final body = { + 'username_or_email': username, + 'password': password, + 'totp_2fa_token': totp, + }; + + final json = await request(HttpMethod.post, '$basePath/user/login', body); + return json['jwt'] as String?; + } + + @override + Future site() async { + final json = await request(HttpMethod.get, '$basePath/site', {}); + return parseSiteResponse(json); + } + + // ============================================================= + // Posts + // ============================================================= + + @override + Future getPost(int postId, {int? commentId}) async { + final json = await request(HttpMethod.get, '$basePath/post', { + 'id': postId, + 'comment_id': commentId, + }); + + final posts = await parsePosts([parsePost(json['post_view'])]); + final moderators = (json['moderators'] as List).map((mu) => parseUser(mu['moderator'])).toList(); + final crossPosts = (json['cross_posts'] as List).map((cp) => parsePost(cp)).toList(); + + return ( + post: posts.first, + moderators: moderators, + crossPosts: crossPosts, + ); + } + + @override + Future getPosts({ + String? cursor, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + int? communityId, + String? communityName, + bool? showHidden, + bool? showSaved, + }) async { + // Use page-based pagination for Lemmy 0.19.x instances + // See https://github.com/LemmyNet/lemmy/issues/6171 + final page = cursor != null ? int.tryParse(cursor) ?? 1 : 1; + + final Map queryParams = { + 'type_': feedListType?.value, + 'sort': postSortType?.value, + 'page': page, + 'limit': limit, + 'community_name': communityName, + 'community_id': communityId, + }; + + if (showSaved == true) queryParams['saved_only'] = showSaved; + if (showHidden == true) queryParams['show_hidden'] = showHidden; + + final json = await request(HttpMethod.get, '$basePath/post/list', queryParams); + + final posts = (json['posts'] as List).map((pv) => parsePost(pv)).toList(); + final nextPage = posts.isNotEmpty ? (page + 1).toString() : null; + + return (posts: posts, nextPage: nextPage); + } + + @override + Future createPost({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + }) async { + final body = { + 'name': title, + 'community_id': communityId, + 'url': url, + 'body': contents, + 'alt_text': altText, + 'nsfw': nsfw, + 'language_id': languageId, + 'custom_thumbnail': customThumbnail, + }; + + final json = await request(HttpMethod.post, '$basePath/post', body); + return parsePost(json['post_view']); + } + + @override + Future editPost({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + }) async { + final body = { + 'post_id': postId, + 'name': title, + 'url': url, + 'body': contents, + 'alt_text': altText, + 'nsfw': nsfw, + 'language_id': languageId, + 'custom_thumbnail': customThumbnail, + }; + + final json = await request(HttpMethod.put, '$basePath/post', body); + return parsePost(json['post_view']); + } + + @override + Future votePost({required int postId, required int score}) async { + final json = await request(HttpMethod.post, '$basePath/post/like', { + 'post_id': postId, + 'score': score, + }); + return parsePost(json['post_view']); + } + + @override + Future savePost({required int postId, required bool save}) async { + final json = await request(HttpMethod.put, '$basePath/post/save', { + 'post_id': postId, + 'save': save, + }); + return parsePost(json['post_view']); + } + + @override + Future readPost({required List postIds, required bool read}) async { + final json = await request(HttpMethod.post, '$basePath/post/mark_as_read', { + 'post_ids': postIds, + 'read': read, + }); + return json['success'] as bool; + } + + @override + Future hidePost({required int postId, required bool hide}) async { + final json = await request(HttpMethod.post, '$basePath/post/hide', { + 'post_ids': [postId], + 'hide': hide, + }); + return json['success'] as bool; + } + + @override + Future deletePost({required int postId, required bool deleted}) async { + final json = await request(HttpMethod.post, '$basePath/post/delete', { + 'post_id': postId, + 'deleted': deleted, + }); + final post = parsePost(json['post_view']); + return post.deleted == deleted; + } + + @override + Future lockPost({required int postId, required bool locked}) async { + final json = await request(HttpMethod.post, '$basePath/post/lock', { + 'post_id': postId, + 'locked': locked, + }); + final post = parsePost(json['post_view']); + return post.locked == locked; + } + + @override + Future pinPost({required int postId, required bool pinned}) async { + final json = await request(HttpMethod.post, '$basePath/post/feature', { + 'post_id': postId, + 'featured': pinned, + 'feature_type': 'Community', + }); + final post = parsePost(json['post_view']); + return post.featuredCommunity == pinned; + } + + @override + Future removePost({required int postId, required bool removed, required String reason}) async { + final json = await request(HttpMethod.post, '$basePath/post/remove', { + 'post_id': postId, + 'removed': removed, + 'reason': reason, + }); + final post = parsePost(json['post_view']); + return post.removed == removed; + } + + @override + Future reportPost({required int postId, required String reason}) async { + await request(HttpMethod.post, '$basePath/post/report', { + 'post_id': postId, + 'reason': reason, + }); + } + + @override + Future> getPostReports({ + int? postId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) async { + final json = await request(HttpMethod.get, '$basePath/post/report/list', { + 'post_id': postId, + 'page': page, + 'limit': limit, + 'unresolved_only': unresolved, + 'community_id': communityId, + }); + return (json['post_reports'] as List).map((pr) => ThunderPostReport.fromLemmyPostReportView(pr)).toList(); + } + + @override + Future resolvePostReport({required int reportId, required bool resolved}) async { + final json = await request(HttpMethod.put, '$basePath/post/report/resolve', { + 'report_id': reportId, + 'resolved': resolved, + }); + return ThunderPostReport.fromLemmyPostReportView(json['post_report_view']); + } + + // ============================================================= + // Comments + // ============================================================= + + @override + Future getComment(int commentId) async { + final json = await request(HttpMethod.get, '$basePath/comment', {'id': commentId}); + return parseComment(json['comment_view']); + } + + @override + Future getComments({ + required int postId, + int? page, + int? limit, + int? maxDepth, + int? communityId, + int? parentId, + CommentSortType? commentSortType, + }) async { + final json = await request(HttpMethod.get, '$basePath/comment/list', { + 'sort': commentSortType?.value, + 'max_depth': maxDepth, + 'page': page, + 'limit': limit, + 'community_id': communityId, + 'post_id': postId, + 'parent_id': parentId, + 'depth_first': true, + 'type_': 'All', + }); + + final comments = (json['comments'] as List).map((cv) => parseComment(cv)).toList(); + final nextPage = (limit != null && comments.length < limit) ? null : (page ?? 0) + 1; + + return (comments: comments, nextPage: nextPage); + } + + @override + Future createComment({ + required int postId, + required String content, + int? parentId, + int? languageId, + }) async { + final json = await request(HttpMethod.post, '$basePath/comment', { + 'post_id': postId, + 'content': content, + 'parent_id': parentId, + 'language_id': languageId, + }); + return parseComment(json['comment_view']); + } + + @override + Future editComment({ + required int commentId, + required String content, + int? languageId, + }) async { + final json = await request(HttpMethod.put, '$basePath/comment', { + 'comment_id': commentId, + 'content': content, + 'language_id': languageId, + }); + return parseComment(json['comment_view']); + } + + @override + Future voteComment({required int commentId, required int score}) async { + final json = await request(HttpMethod.post, '$basePath/comment/like', { + 'comment_id': commentId, + 'score': score, + }); + return parseComment(json['comment_view']); + } + + @override + Future saveComment({required int commentId, required bool save}) async { + final json = await request(HttpMethod.put, '$basePath/comment/save', { + 'comment_id': commentId, + 'save': save, + }); + return parseComment(json['comment_view']); + } + + @override + Future deleteComment({required int commentId, required bool deleted}) async { + final json = await request(HttpMethod.post, '$basePath/comment/delete', { + 'comment_id': commentId, + 'deleted': deleted, + }); + return parseComment(json['comment_view']); + } + + @override + Future reportComment({required int commentId, required String reason}) async { + await request(HttpMethod.post, '$basePath/comment/report', { + 'comment_id': commentId, + 'reason': reason, + }); + } + + @override + Future> getCommentReports({ + int? commentId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) async { + final json = await request(HttpMethod.get, '$basePath/comment/report/list', { + 'comment_id': commentId, + 'page': page, + 'limit': limit, + 'unresolved_only': unresolved, + 'community_id': communityId, + }); + return (json['comment_reports'] as List).map((cr) => ThunderCommentReport.fromLemmyCommentReportView(cr)).toList(); + } + + @override + Future resolveCommentReport({required int reportId, required bool resolved}) async { + final json = await request(HttpMethod.put, '$basePath/comment/report/resolve', { + 'report_id': reportId, + 'resolved': resolved, + }); + return ThunderCommentReport.fromLemmyCommentReportView(json['comment_report_view']); + } + + // ============================================================= + // Communities + // ============================================================= + + @override + Future getCommunity({int? id, String? name}) async { + final json = await request(HttpMethod.get, '$basePath/community', { + 'id': id, + 'name': name, + }); + + return ( + community: parseCommunityView(json['community_view']), + site: json['site'] != null ? ThunderSite.fromLemmySite(json['site']) : null, + moderators: (json['moderators'] as List).map((cmv) => parseUser(cmv['moderator'])).toList(), + discussionLanguages: (json['discussion_languages'] as List).cast(), + ); + } + + @override + Future> getCommunities({ + int? page, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + }) async { + final json = await request(HttpMethod.get, '$basePath/community/list', { + 'page': page, + 'limit': limit, + 'type_': feedListType?.value, + 'sort': postSortType?.value, + }); + return (json['communities'] as List).map((cv) => parseCommunityView(cv)).toList(); + } + + @override + Future subscribeToCommunity({required int communityId, required bool follow}) async { + final json = await request(HttpMethod.post, '$basePath/community/follow', { + 'community_id': communityId, + 'follow': follow, + }); + return parseCommunityView(json['community_view']); + } + + @override + Future blockCommunity({required int communityId, required bool block}) async { + final json = await request(HttpMethod.post, '$basePath/community/block', { + 'community_id': communityId, + 'block': block, + }); + return parseCommunityView(json['community_view']); + } + + // ============================================================= + // Users + // ============================================================= + + @override + Future getUser({ + int? userId, + String? username, + PostSortType? sort, + int? page, + int? limit, + bool? saved, + }) async { + final json = await request(HttpMethod.get, '$basePath/user', { + 'person_id': userId, + 'username': username, + 'sort': sort?.value, + 'page': page, + 'limit': limit, + 'saved_only': saved, + }); + + return ( + user: parseUserView(json['person_view']), + site: json['site'] != null ? ThunderSite.fromLemmySite(json['site']) : null, + posts: (json['posts'] as List).map((pv) => parsePost(pv)).toList(), + comments: (json['comments'] as List).map((cv) => parseComment(cv)).toList(), + moderates: (json['moderates'] as List).map((cmv) => parseCommunity(cmv['community'])).toList(), + ); + } + + @override + Future blockUser({required int userId, required bool block}) async { + final json = await request(HttpMethod.post, '$basePath/user/block', { + 'person_id': userId, + 'block': block, + }); + return parseUserView(json['person_view']); + } + + @override + Future banUserFromCommunity({ + required int userId, + required int communityId, + required bool ban, + bool? removeData, + String? reason, + int? expires, + }) async { + final json = await request(HttpMethod.post, '$basePath/community/ban_user', { + 'person_id': userId, + 'community_id': communityId, + 'ban': ban, + 'remove_data': removeData, + 'reason': reason, + 'expires': expires, + }); + return parseUserView(json['person_view']); + } + + @override + Future> addModerator({ + required int userId, + required int communityId, + required bool added, + }) async { + final json = await request(HttpMethod.post, '$basePath/community/mod', { + 'person_id': userId, + 'community_id': communityId, + 'added': added, + }); + return (json['moderators'] as List).map((cmv) => parseUser(cmv['moderator'])).toList(); + } + + // ============================================================= + // Search + // ============================================================= + + @override + Future search({ + required String query, + int? communityId, + String? communityName, + int? creatorId, + MetaSearchType? type, + SearchSortType? sort, + FeedListType? listingType, + int? page, + int? limit, + }) async { + final json = await request(HttpMethod.get, '$basePath/search', { + 'q': query, + 'community_id': communityId, + 'community_name': communityName, + 'creator_id': creatorId, + 'type_': type?.searchType, + 'sort': sort?.value, + 'listing_type': listingType?.value, + 'page': page, + 'limit': limit, + }); + + return ( + type: MetaSearchType.values.firstWhere((e) => e.searchType == json['type_']), + posts: (json['posts'] as List?)?.map((pv) => parsePost(pv)).toList() ?? [], + comments: (json['comments'] as List?)?.map((cv) => parseComment(cv)).toList() ?? [], + communities: (json['communities'] as List?)?.map((cv) => parseCommunityView(cv)).toList() ?? [], + users: (json['users'] as List?)?.map((pv) => parseUserView(pv)).toList() ?? [], + ); + } + + @override + Future resolve({required String query}) async { + final json = await request(HttpMethod.get, '$basePath/resolve_object', {'q': query}); + + return ( + community: json['community'] != null ? parseCommunityView(json['community']) : null, + post: json['post'] != null ? parsePost(json['post']) : null, + comment: json['comment'] != null ? parseComment(json['comment']) : null, + user: json['person'] != null ? parseUserView(json['person']) : null, + ); + } + + // ============================================================= + // Notifications + // ============================================================= + + @override + Future unreadCount() async { + final json = await request(HttpMethod.get, '$basePath/user/unread_count', {}); + return ( + replies: json['replies'] as int, + mentions: json['mentions'] as int, + privateMessages: json['private_messages'] as int, + ); + } + + @override + Future> getCommentReplies({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }) async { + final response = await request(HttpMethod.get, '$basePath/user/replies', { + 'page': page, + 'limit': limit, + 'sort': sort?.value, + 'unread_only': unread, + }); + + return (response['replies'] as List).map((crv) { + // Parse the full comment reply view (includes post, creator info, etc.) + final comment = parseComment(crv); + + return comment.copyWith( + recipient: parseUser(crv['recipient']), + replyMentionId: crv['comment_reply']['id'], + read: crv['comment_reply']['read'], + ); + }).toList(); + } + + @override + Future markCommentReplyAsRead({required int replyId, required bool read}) async { + await request(HttpMethod.post, '$basePath/comment/mark_as_read', { + 'comment_reply_id': replyId, + 'read': read, + }); + } + + @override + Future> getCommentMentions({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }) async { + final response = await request(HttpMethod.get, '$basePath/user/mention', { + 'page': page, + 'limit': limit, + 'sort': sort?.value, + 'unread_only': unread, + }); + + return (response['mentions'] as List).map((mention) { + // Parse the full mention view (includes post, creator info, etc.) + final comment = parseComment(mention); + + return comment.copyWith( + recipient: parseUser(mention['recipient']), + replyMentionId: mention['person_mention']['id'], + read: mention['person_mention']['read'], + ); + }).toList(); + } + + @override + Future markCommentMentionAsRead({required int mentionId, required bool read}) async { + await request(HttpMethod.post, '$basePath/user/mention/mark_as_read', { + 'person_mention_id': mentionId, + 'read': read, + }); + } + + @override + Future markAllNotificationsAsRead() async { + await request(HttpMethod.post, '$basePath/user/mark_all_as_read', {}); + } + + // ============================================================= + // Private Messages + // ============================================================= + + @override + Future> getPrivateMessages({ + int? page, + int? limit, + bool unread = false, + int? creatorId, + }) async { + final json = await request(HttpMethod.get, '$basePath/private_message/list', { + 'page': page, + 'limit': limit, + 'unread_only': unread, + 'creator_id': creatorId, + }); + return (json['private_messages'] as List).map((pm) => ThunderPrivateMessage.fromLemmyPrivateMessageView(pm)).toList(); + } + + @override + Future markPrivateMessageAsRead({required int messageId, required bool read}) async { + await request(HttpMethod.post, '$basePath/private_message/mark_as_read', { + 'private_message_id': messageId, + 'read': read, + }); + } + + // ============================================================= + // Account Settings + // ============================================================= + + @override + Future saveUserSettings({ + String? bio, + String? email, + String? matrixUserId, + String? displayName, + FeedListType? defaultFeedListType, + PostSortType? defaultPostSortType, + bool? showNsfw, + bool? showReadPosts, + bool? showScores, + bool? botAccount, + bool? showBotAccounts, + List? discussionLanguages, + }) async { + await request(HttpMethod.put, '$basePath/user/save_user_settings', { + 'bio': bio, + 'email': email, + 'matrix_user_id': matrixUserId, + 'display_name': displayName, + 'default_listing_type': defaultFeedListType?.value, + 'default_sort_type': defaultPostSortType?.value, + 'show_nsfw': showNsfw, + 'show_read_posts': showReadPosts, + 'show_scores': showScores, + 'bot_account': botAccount, + 'show_bot_accounts': showBotAccounts, + 'discussion_languages': discussionLanguages, + }); + } + + @override + Future importSettings(String settings) async { + final json = await request(HttpMethod.post, '$basePath/user/import_settings', { + 'data': settings, + }); + return json['success'] as bool; + } + + @override + Future exportSettings() async { + return await request(HttpMethod.get, '$basePath/user/export_settings', {}); + } + + @override + Future> media({int? page, int? limit}) async { + return await request(HttpMethod.get, '$basePath/account/list_media', { + 'page': page, + 'limit': limit, + }); + } + + // ============================================================= + // Modlog + // ============================================================= + + @override + Future> getModlog({ + int? page, + int? limit, + ModlogActionType? modlogActionType, + int? communityId, + int? userId, + int? moderatorId, + int? commentId, + }) async { + final response = await request(HttpMethod.get, '$basePath/modlog', { + 'page': page, + 'limit': limit, + 'type_': modlogActionType?.value, + 'community_id': communityId, + 'other_person_id': userId, + 'mod_person_id': moderatorId, + 'comment_id': commentId, + }); + + List items = []; + + // Convert the response to a list of modlog events + List removedPosts = (response['removed_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemovePost, e)).toList(); + List lockedPosts = (response['locked_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modLockPost, e)).toList(); + List featuredPosts = (response['featured_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modFeaturePost, e)).toList(); + List removedComments = (response['removed_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveComment, e)).toList(); + List removedCommunities = (response['removed_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveCommunity, e)).toList(); + List bannedFromCommunity = (response['banned_from_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modBanFromCommunity, e)).toList(); + List banned = (response['banned'] as List).map((e) => parseModlogEvent(ModlogActionType.modBan, e)).toList(); + List addedToCommunity = (response['added_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modAddCommunity, e)).toList(); + List transferredToCommunity = (response['transferred_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modTransferCommunity, e)).toList(); + List added = (response['added'] as List).map((e) => parseModlogEvent(ModlogActionType.modAdd, e)).toList(); + List adminPurgedPersons = (response['admin_purged_persons'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePerson, e)).toList(); + List adminPurgedCommunities = (response['admin_purged_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeCommunity, e)).toList(); + List adminPurgedPosts = (response['admin_purged_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePost, e)).toList(); + List adminPurgedComments = (response['admin_purged_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeComment, e)).toList(); + List hiddenCommunities = (response['hidden_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modHideCommunity, e)).toList(); + + items.addAll(removedPosts); + items.addAll(lockedPosts); + items.addAll(featuredPosts); + items.addAll(removedComments); + items.addAll(removedCommunities); + items.addAll(bannedFromCommunity); + items.addAll(banned); + items.addAll(addedToCommunity); + items.addAll(transferredToCommunity); + items.addAll(added); + items.addAll(adminPurgedPersons); + items.addAll(adminPurgedCommunities); + items.addAll(adminPurgedPosts); + items.addAll(adminPurgedComments); + items.addAll(hiddenCommunities); + + return items; + } + + /// Given a modlog event, return a normalized [ModlogEventItem]. + ModlogEventItem parseModlogEvent(ModlogActionType type, dynamic event) { + switch (type) { + case ModlogActionType.modRemovePost: + return ModlogEventItem( + type: type, + dateTime: event['mod_remove_post']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + reason: event['mod_remove_post']['reason'], + post: ThunderPost.fromLemmyPost(event['post']), + community: parseCommunity(event['community']), + actioned: event['mod_remove_post']['removed'], + ); + case ModlogActionType.modLockPost: + return ModlogEventItem( + type: type, + dateTime: event['mod_lock_post']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + post: ThunderPost.fromLemmyPost(event['post']), + community: parseCommunity(event['community']), + actioned: event['mod_lock_post']['locked'], + ); + case ModlogActionType.modFeaturePost: + return ModlogEventItem( + type: type, + dateTime: event['mod_feature_post']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + post: ThunderPost.fromLemmyPost(event['post']), + community: parseCommunity(event['community']), + actioned: event['mod_feature_post']['featured'], + ); + case ModlogActionType.modRemoveComment: + return ModlogEventItem( + type: type, + dateTime: event['mod_remove_comment']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + reason: event['mod_remove_comment']['reason'], + user: event['commenter'] != null ? parseUser(event['commenter']) : null, + post: ThunderPost.fromLemmyPost(event['post']), + comment: ThunderComment.fromLemmyComment(event['comment']), + community: parseCommunity(event['community']), + actioned: event['mod_remove_comment']['removed'], + ); + case ModlogActionType.modRemoveCommunity: + return ModlogEventItem( + type: type, + dateTime: event['mod_remove_community']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + reason: event['mod_remove_community']['reason'], + community: parseCommunity(event['community']), + actioned: event['mod_remove_community']['removed'], + ); + case ModlogActionType.modBanFromCommunity: + return ModlogEventItem( + type: type, + dateTime: event['mod_ban_from_community']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + reason: event['mod_ban_from_community']['reason'], + user: event['banned_person'] != null ? parseUser(event['banned_person']) : null, + community: parseCommunity(event['community']), + actioned: event['mod_ban_from_community']['banned'], + ); + case ModlogActionType.modBan: + return ModlogEventItem( + type: type, + dateTime: event['mod_ban']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + reason: event['mod_ban']['reason'], + user: event['banned_person'] != null ? parseUser(event['banned_person']) : null, + actioned: event['mod_ban']['banned'], + ); + case ModlogActionType.modAddCommunity: + return ModlogEventItem( + type: type, + dateTime: event['mod_add_community']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + user: event['modded_person'] != null ? parseUser(event['modded_person']) : null, + community: parseCommunity(event['community']), + actioned: !event['mod_add_community']['removed'], + ); + case ModlogActionType.modTransferCommunity: + return ModlogEventItem( + type: type, + dateTime: event['mod_transfer_community']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + user: event['modded_person'] != null ? parseUser(event['modded_person']) : null, + community: parseCommunity(event['community']), + actioned: true, + ); + case ModlogActionType.modAdd: + return ModlogEventItem( + type: type, + dateTime: event['mod_add']['when_'], + moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, + user: event['modded_person'] != null ? parseUser(event['modded_person']) : null, + actioned: !event['mod_add']['removed'], + ); + case ModlogActionType.adminPurgePerson: + return ModlogEventItem( + type: type, + dateTime: event['admin_purge_person']['when_'], + admin: event['admin'] != null ? parseUser(event['admin']) : null, + reason: event['admin_purge_person']['reason'], + actioned: true, + ); + case ModlogActionType.adminPurgeCommunity: + return ModlogEventItem( + type: type, + dateTime: event['admin_purge_community']['when_'], + admin: event['admin'] != null ? parseUser(event['admin']) : null, + reason: event['admin_purge_community']['reason'], + actioned: true, + ); + case ModlogActionType.adminPurgePost: + return ModlogEventItem( + type: type, + dateTime: event['admin_purge_post']['when_'], + admin: event['admin'] != null ? parseUser(event['admin']) : null, + reason: event['admin_purge_post']['reason'], + actioned: true, + ); + case ModlogActionType.adminPurgeComment: + return ModlogEventItem( + type: type, + dateTime: event['admin_purge_comment']['when_'], + admin: event['admin'] != null ? parseUser(event['admin']) : null, + reason: event['admin_purge_comment']['reason'], + actioned: true, + ); + case ModlogActionType.modHideCommunity: + return ModlogEventItem( + type: type, + dateTime: event['mod_hide_community']['when'], + admin: event['admin'] != null ? parseUser(event['admin']) : null, + reason: event['mod_hide_community']['reason'], + community: parseCommunity(event['community']), + actioned: event['mod_hide_community']['hidden'], + ); + default: + throw Exception('Unknown modlog type: $type'); + } + } + + // ============================================================= + // Instance + // ============================================================= + + @override + Future> federated() async { + return await request(HttpMethod.get, '$basePath/federated_instances', {}); + } + + @override + Future blockInstance({required int instanceId, required bool block}) async { + final json = await request(HttpMethod.post, '$basePath/site/block', { + 'instance_id': instanceId, + 'block': block, + }); + return json['blocked'] as bool; + } + + // ============================================================= + // Media + // ============================================================= + + @override + Future> uploadImage(String filePath) async { + try { + final uploadRequest = http.MultipartRequest( + 'POST', + Uri.https(account.instance, '/pictrs/image'), + ); + uploadRequest.headers.addAll(buildHeaders()); + uploadRequest.files.add(await http.MultipartFile.fromPath('images[]', filePath)); + + final response = await uploadRequest.send(); + if (response.statusCode != 201) { + throw ApiErrorException( + 'Failed to upload image: ${response.statusCode} ${response.reasonPhrase}', + statusCode: response.statusCode, + platformName: platformName, + ); + } + + final responseBody = await response.stream.bytesToString(); + return jsonDecode(responseBody) as Map; + } catch (e) { + if (e is ApiException) rethrow; + throw ApiErrorException('Failed to upload image: $e', platformName: platformName); + } + } + + @override + Future deleteImage({required String file, required String token}) async { + await request(HttpMethod.get, '/pictrs/image/delete/$token/$file', {}); + } + + // ============================================================= + // Feature Flags + // ============================================================= + + @override + bool get supportsHidePosts => true; + + @override + bool get supportsPostReports => true; + + @override + bool get supportsCommentReports => true; + + @override + bool get supportsPrivateMessages => true; + + @override + bool get supportsModlog => true; + + @override + bool get supportsSettingsImportExport => true; + + @override + bool get supportsMedia => true; + + @override + bool get supportsTOTP => true; + + @override + bool get supportsInstanceBlock => true; +} diff --git a/lib/src/core/network/lemmy/lemmy_v3_api_client.dart b/lib/src/core/network/lemmy/lemmy_v3_api_client.dart new file mode 100644 index 000000000..845a51f0d --- /dev/null +++ b/lib/src/core/network/lemmy/lemmy_v3_api_client.dart @@ -0,0 +1,61 @@ +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/core/network/lemmy/base_lemmy_api_client.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Lemmy API client for version 0.19.x (v3 API). +/// +/// This client uses the `/api/v3` endpoints and the original JSON schema +/// with field names like `actor_id`, `published`, etc. +class LemmyV3ApiClient extends BaseLemmyApiClient { + LemmyV3ApiClient({ + required super.account, + super.debug, + required super.version, + super.httpClient, + }); + + @override + String get basePath => '/api/v3'; + + // ============================================================= + // Version-specific parsing methods + // ============================================================= + + @override + ThunderPost parsePost(Map json) { + return ThunderPost.fromLemmyPostView(json); + } + + @override + ThunderComment parseComment(Map json) { + return ThunderComment.fromLemmyCommentView(json); + } + + @override + ThunderUser parseUser(Map json) { + return ThunderUser.fromLemmyUser(json); + } + + @override + ThunderUser parseUserView(Map json) { + return ThunderUser.fromLemmyUserView(json); + } + + @override + ThunderCommunity parseCommunity(Map json) { + return ThunderCommunity.fromLemmyCommunity(json); + } + + @override + ThunderCommunity parseCommunityView(Map json) { + return ThunderCommunity.fromLemmyCommunityView(json); + } + + @override + ThunderSiteResponse parseSiteResponse(Map json) { + return ThunderSiteResponse.fromLemmySiteResponse(json); + } +} diff --git a/lib/src/core/network/lemmy/lemmy_v4_api_client.dart b/lib/src/core/network/lemmy/lemmy_v4_api_client.dart new file mode 100644 index 000000000..a7f7655ab --- /dev/null +++ b/lib/src/core/network/lemmy/lemmy_v4_api_client.dart @@ -0,0 +1,95 @@ +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/core/network/lemmy/base_lemmy_api_client.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Lemmy API client for version 1.0.0+ (v4 API). +/// +/// This client uses the `/api/v4` endpoints. When the v4 schema differs +/// from v3, this class should override the appropriate methods. +/// +/// Note: As of this implementation, the parsing methods use the same +/// factories as v3. When Lemmy v4 introduces schema changes, create +/// new `fromLemmyV4*` factory constructors in the model classes and +/// update this client to use them. +class LemmyV4ApiClient extends BaseLemmyApiClient { + LemmyV4ApiClient({ + required super.account, + super.debug, + required super.version, + super.httpClient, + }); + + @override + String get basePath => '/api/v4'; + + // ============================================================= + // Version-specific parsing methods + // ============================================================= + // + // TODO: When Lemmy v4 introduces schema changes: + // 1. Create new `fromLemmyV4*` factory constructors in model classes + // 2. Update these methods to use the v4 factories + // 3. Override any methods in BaseLemmyApiClient that have different + // endpoints or request/response formats in v4 + + @override + ThunderPost parsePost(Map json) { + // TODO: Replace with ThunderPost.fromLemmyV4PostView when v4 schema differs + return ThunderPost.fromLemmyPostView(json); + } + + @override + ThunderComment parseComment(Map json) { + // TODO: Replace with ThunderComment.fromLemmyV4CommentView when v4 schema differs + return ThunderComment.fromLemmyCommentView(json); + } + + @override + ThunderUser parseUser(Map json) { + // TODO: Replace with ThunderUser.fromLemmyV4User when v4 schema differs + return ThunderUser.fromLemmyUser(json); + } + + @override + ThunderUser parseUserView(Map json) { + // TODO: Replace with ThunderUser.fromLemmyV4UserView when v4 schema differs + return ThunderUser.fromLemmyUserView(json); + } + + @override + ThunderCommunity parseCommunity(Map json) { + // TODO: Replace with ThunderCommunity.fromLemmyV4Community when v4 schema differs + return ThunderCommunity.fromLemmyCommunity(json); + } + + @override + ThunderCommunity parseCommunityView(Map json) { + // TODO: Replace with ThunderCommunity.fromLemmyV4CommunityView when v4 schema differs + return ThunderCommunity.fromLemmyCommunityView(json); + } + + @override + ThunderSiteResponse parseSiteResponse(Map json) { + // TODO: Replace with ThunderSiteResponse.fromLemmyV4SiteResponse when v4 schema differs + return ThunderSiteResponse.fromLemmySiteResponse(json); + } + + // ============================================================= + // V4-specific endpoint overrides + // ============================================================= + // + // Override methods from BaseLemmyApiClient here when the v4 API + // differs from v3 in: + // - Endpoint paths + // - Request parameters + // - Response structure + // + // Example: + // @override + // Future getPosts({...}) async { + // // V4-specific implementation + // } +} diff --git a/lib/src/core/network/lemmy_api.dart b/lib/src/core/network/lemmy_api.dart deleted file mode 100644 index c2f5d9e64..000000000 --- a/lib/src/core/network/lemmy_api.dart +++ /dev/null @@ -1,781 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; - -import 'package:http/http.dart'; -import 'package:version/version.dart'; - -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; - -enum HttpMethod { get, post, put, delete } - -class LemmyApiException implements Exception { - /// The error message - final String message; - - /// The error code - final String errorCode; - - LemmyApiException(this.message, this.errorCode); -} - -class LemmyApi { - /// The account to use for API calls - final Account account; - - /// Whether to show debug information - final bool debug; - - /// The version of the platform - final Version? version; - - /// The Lemmy API client - LemmyApi({required this.account, this.debug = false, required this.version}); - - /// Build headers with optional JWT authorization - Map _buildHeaders() { - final version = getCurrentVersion(removeInternalBuildNumber: true, trimV: true); - final userAgent = 'Thunder/$version'; - - Map headers = { - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }; - - if (account.jwt != null) headers['Authorization'] = 'Bearer ${account.jwt}'; - return headers; - } - - /// Handle response from the request. Throws an exception if the request fails. - Future _handleResponse(Uri uri, Response response) { - if (response.statusCode != 200) { - debugPrint('Lemmy API: Failed to make request to $uri: ${response.statusCode} ${response.body}'); - throw LemmyApiException(response.body, response.statusCode.toString()); - } - - return compute(jsonDecode, response.body); - } - - /// Makes an HTTP request with the specified method - Future> _request(HttpMethod method, String endpoint, Map data) async { - try { - final headers = _buildHeaders(); - - Uri uri = Uri.https(account.instance, endpoint); - Response response; - - data.removeWhere((key, value) => value == null); - - if (method == HttpMethod.get) { - // Remove null values and convert values to strings - data = data.map((key, value) => MapEntry(key, value.toString())); - - uri = Uri.https(account.instance, endpoint, data); - if (debug) debugPrint('Lemmy API: GET $uri'); - - response = await get(uri, headers: headers); - } else { - uri = Uri.https(account.instance, endpoint); - - switch (method) { - case HttpMethod.post: - if (debug) debugPrint('Lemmy API: POST $uri'); - response = await post(uri, body: jsonEncode(data), headers: headers); - break; - case HttpMethod.put: - if (debug) debugPrint('Lemmy API: PUT $uri'); - response = await put(uri, body: jsonEncode(data), headers: headers); - break; - default: - throw ArgumentError('Unsupported HTTP method: $method'); - } - } - - return await _handleResponse(uri, response); - } catch (e) { - if (debug) debugPrint('Lemmy API: Error: $e'); - rethrow; - } - } - - /// Login - Future login({required String username, required String password, String? totp}) async { - final body = { - 'username_or_email': username, - 'password': password, - 'totp_2fa_token': totp, - }; - - final json = await _request(HttpMethod.post, '/api/v3/user/login', body); - return json['jwt']; - } - - /// Get site info - Future site() async { - final json = await _request(HttpMethod.get, '/api/v3/site', {}); - - final siteResponse = ThunderSiteResponse.fromLemmySiteResponse(json); - return siteResponse; - } - - /// Get media - Future> media({int? page, int? limit}) async { - final json = await _request(HttpMethod.get, '/api/v3/account/list_media', {'page': page, 'limit': limit}); - return json; - } - - /// Save user settings - Future saveUserSettings({ - String? bio, - String? email, - String? matrixUserId, - String? displayName, - FeedListType? defaultFeedListType, - PostSortType? defaultPostSortType, - bool? showNsfw, - bool? showReadPosts, - bool? showScores, - bool? botAccount, - bool? showBotAccounts, - List? discussionLanguages, - }) async { - final body = { - 'bio': bio, - 'email': email, - 'matrix_user_id': matrixUserId, - 'display_name': displayName, - 'default_listing_type': defaultFeedListType?.value, - 'default_sort_type': defaultPostSortType?.value, - 'show_nsfw': showNsfw, - 'show_read_posts': showReadPosts, - 'show_scores': showScores, - 'bot_account': botAccount, - 'show_bot_accounts': showBotAccounts, - 'discussion_languages': discussionLanguages, - }; - - await _request(HttpMethod.put, '/api/v3/user/save_user_settings', body); - } - - /// Import settings - Future importSettings(String settings) async { - final body = {'data': settings}; - - final json = await _request(HttpMethod.post, '/api/v3/user/import_settings', body); - return json['success']; - } - - /// Export settings - Future exportSettings() async { - final json = await _request(HttpMethod.get, '/api/v3/user/export_settings', {}); - return json; - } - - /// Fetches a post from the Lemmy API - Future> getPost(int postId, {int? commentId}) async { - final queryParams = {'id': postId, 'comment_id': commentId}; - - final json = await _request(HttpMethod.get, '/api/v3/post', queryParams); - - final posts = await parsePosts([ThunderPost.fromLemmyPostView(json['post_view'])]); - final moderators = json['moderators'].map((mu) => ThunderUser.fromLemmyUser(mu['moderator'])).toList(); - final crossPosts = json['cross_posts'].map((cp) => ThunderPost.fromLemmyPostView(cp)).toList(); - - return { - 'post': posts.first, - 'moderators': moderators, - 'crossPosts': crossPosts, - }; - } - - /// Fetches a list of posts from the Lemmy API - Future> getPosts({ - int? page, - String? cursor, - int? limit, - FeedListType? feedListType, - PostSortType? postSortType, - int? communityId, - String? communityName, - bool? showHidden, - bool? showSaved, - }) async { - Map queryParams = { - 'type_': feedListType?.value, - 'sort': postSortType?.value, - 'page_cursor': cursor, - 'page': page, - 'limit': limit, - 'community_name': communityName, - 'community_id': communityId, - }; - - if (showSaved == true) queryParams['saved_only'] = showSaved; - if (showHidden == true) queryParams['show_hidden'] = showHidden; - - final json = await _request(HttpMethod.get, '/api/v3/post/list', queryParams); - return { - 'posts': json['posts'].map((pv) => ThunderPost.fromLemmyPostView(pv)).toList(), - 'next_page': json['next_page'], - }; - } - - /// Creates a post - Future createPost({ - required String title, - required int communityId, - String? url, - String? contents, - bool? nsfw, - int? languageId, - String? customThumbnail, - String? altText, - }) async { - final body = { - 'name': title, - 'community_id': communityId, - 'url': url, - 'body': contents, - 'alt_text': altText, - 'nsfw': nsfw, - 'language_id': languageId, - 'custom_thumbnail': customThumbnail, - }; - - final json = await _request(HttpMethod.post, '/api/v3/post', body); - return ThunderPost.fromLemmyPostView(json['post_view']); - } - - /// Edits a post - Future editPost({ - required int postId, - required String title, - String? url, - String? contents, - String? altText, - bool? nsfw, - int? languageId, - String? customThumbnail, - }) async { - final body = { - 'post_id': postId, - 'name': title, - 'url': url, - 'body': contents, - 'alt_text': altText, - 'nsfw': nsfw, - 'language_id': languageId, - 'custom_thumbnail': customThumbnail, - }; - - final json = await _request(HttpMethod.put, '/api/v3/post', body); - return ThunderPost.fromLemmyPostView(json['post_view']); - } - - /// Votes on a post - Future votePost({required int postId, required int score}) async { - final body = {'post_id': postId, 'score': score}; - - final json = await _request(HttpMethod.post, '/api/v3/post/like', body); - return ThunderPost.fromLemmyPostView(json['post_view']); - } - - /// Saves a post - Future savePost({required int postId, required bool save}) async { - final body = {'post_id': postId, 'save': save}; - - final json = await _request(HttpMethod.put, '/api/v3/post/save', body); - return ThunderPost.fromLemmyPostView(json['post_view']); - } - - /// Marks a set of posts as read - Future readPost({required List postIds, required bool read}) async { - Map body = {'post_ids': postIds, 'read': read}; - - final json = await _request(HttpMethod.post, '/api/v3/post/mark_as_read', body); - return json['success']; - } - - /// Hides a post - Future hidePost({required int postId, required bool hide}) async { - final body = { - 'post_ids': [postId], - 'hide': hide - }; - - final json = await _request(HttpMethod.post, '/api/v3/post/hide', body); - return json['success']; - } - - /// Deletes a post - Future deletePost({required int postId, required bool deleted}) async { - final body = {'post_id': postId, 'deleted': deleted}; - - final json = await _request(HttpMethod.post, '/api/v3/post/delete', body); - final post = ThunderPost.fromLemmyPostView(json['post_view']); - return post.deleted == deleted; - } - - /// Locks a post - Future lockPost({required int postId, required bool locked}) async { - final body = {'post_id': postId, 'locked': locked}; - - final json = await _request(HttpMethod.post, '/api/v3/post/lock', body); - final post = ThunderPost.fromLemmyPostView(json['post_view']); - return post.locked == locked; - } - - /// Pins a post to the community - Future pinPost({required int postId, required bool pinned}) async { - final body = {'post_id': postId, 'featured': pinned, 'feature_type': 'Community'}; - - final json = await _request(HttpMethod.post, '/api/v3/post/feature', body); - final post = ThunderPost.fromLemmyPostView(json['post_view']); - return post.featuredCommunity == pinned; - } - - /// Removes a post - Future removePost({required int postId, required bool removed, required String reason}) async { - final body = {'post_id': postId, 'removed': removed, 'reason': reason}; - - final json = await _request(HttpMethod.post, '/api/v3/post/remove', body); - final post = ThunderPost.fromLemmyPostView(json['post_view']); - return post.removed == removed; - } - - /// Reports a post - Future reportPost({required int postId, required String reason}) async { - final body = {'post_id': postId, 'reason': reason}; - - await _request(HttpMethod.post, '/api/v3/post/report', body); - } - - /// Fetches a list of post reports from the Lemmy API - Future> getPostReports({int? postId, int page = 1, int limit = 20, bool unresolved = false, int? communityId}) async { - final body = { - 'post_id': postId, - 'page': page, - 'limit': limit, - 'unresolved_only': unresolved, - 'community_id': communityId, - }; - - final json = await _request(HttpMethod.get, '/api/v3/post/report/list', body); - return json['post_reports'].map((pr) => ThunderPostReport.fromLemmyPostReportView(pr)).toList(); - } - - /// Resolves a post report - Future resolvePostReport({required int reportId, required bool resolved}) async { - final body = {'report_id': reportId, 'resolved': resolved}; - - final json = await _request(HttpMethod.put, '/api/v3/post/report/resolve', body); - return ThunderPostReport.fromLemmyPostReportView(json['post_report_view']); - } - - /// Fetches a comment from the Lemmy API - Future getComment(int commentId) async { - final queryParams = {'id': commentId}; - - final json = await _request(HttpMethod.get, '/api/v3/comment', queryParams); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Fetches a list of comments from the Lemmy API - Future> getComments({ - required int postId, - int? page, - int? limit, - int? maxDepth, - int? communityId, - int? parentId, - CommentSortType? commentSortType, - }) async { - Map body = { - 'sort': commentSortType?.value, - 'max_depth': maxDepth, - 'page': page, - 'limit': limit, - 'community_id': communityId, - 'post_id': postId, - 'parent_id': parentId, - 'depth_first': true, - 'type_': 'All', - }; - - final json = await _request(HttpMethod.get, '/api/v3/comment/list', body); - - final comments = json['comments'].map((cv) => ThunderComment.fromLemmyCommentView(cv)).toList(); - final nextPage = comments.length < limit ? null : (page ?? 0) + 1; - - return { - 'comments': comments, - 'next_page': nextPage, - }; - } - - /// Creates a comment - Future createComment({required int postId, required String content, int? parentId, int? languageId}) async { - final body = { - 'post_id': postId, - 'content': content, - 'parent_id': parentId, - 'language_id': languageId, - }; - - final json = await _request(HttpMethod.post, '/api/v3/comment', body); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Edits a comment - Future editComment({required int commentId, required String content, int? languageId}) async { - final body = {'comment_id': commentId, 'content': content, 'language_id': languageId}; - - final json = await _request(HttpMethod.put, '/api/v3/comment', body); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Votes on a comment - Future voteComment({required int commentId, required int score}) async { - final body = {'comment_id': commentId, 'score': score}; - - final json = await _request(HttpMethod.post, '/api/v3/comment/like', body); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Saves a comment - Future saveComment({required int commentId, required bool save}) async { - final body = {'comment_id': commentId, 'save': save}; - - final json = await _request(HttpMethod.put, '/api/v3/comment/save', body); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Deletes a comment - Future deleteComment({required int commentId, required bool deleted}) async { - final body = {'comment_id': commentId, 'deleted': deleted}; - - final json = await _request(HttpMethod.post, '/api/v3/comment/delete', body); - return ThunderComment.fromLemmyCommentView(json['comment_view']); - } - - /// Reports a comment - Future reportComment({required int commentId, required String reason}) async { - final body = {'comment_id': commentId, 'reason': reason}; - - await _request(HttpMethod.post, '/api/v3/comment/report', body); - } - - /// Fetches a list of comment reports from the Lemmy API - Future> getCommentReports({int? commentId, int page = 1, int limit = 20, bool unresolved = false, int? communityId}) async { - final body = { - 'comment_id': commentId, - 'page': page, - 'limit': limit, - 'unresolved_only': unresolved, - 'community_id': communityId, - }; - - final json = await _request(HttpMethod.get, '/api/v3/comment/report/list', body); - return json['comment_reports'].map((cr) => ThunderCommentReport.fromLemmyCommentReportView(cr)).toList(); - } - - /// Resolves a comment report - Future resolveCommentReport({required int reportId, required bool resolved}) async { - final body = {'report_id': reportId, 'resolved': resolved}; - - final json = await _request(HttpMethod.put, '/api/v3/comment/report/resolve', body); - return ThunderCommentReport.fromLemmyCommentReportView(json['comment_report_view']); - } - - /// Searches for posts, comments, communities, and users - Future> search({ - required String query, - int? communityId, - String? communityName, - int? creatorId, - MetaSearchType? type, - SearchSortType? sort, - FeedListType? listingType, - int? page, - int? limit, - }) async { - final body = { - 'q': query, - 'community_id': communityId, - 'community_name': communityName, - 'creator_id': creatorId, - 'type_': type?.searchType, - 'sort': sort?.value, - 'listing_type': listingType?.value, - 'page': page, - 'limit': limit, - }; - - final json = await _request(HttpMethod.get, '/api/v3/search', body); - return json; - } - - /// Resolves a given query - Future> resolve({required String query}) async { - final body = {'q': query}; - final json = await _request(HttpMethod.get, '/api/v3/resolve_object', body); - - return { - 'community': json['community'] != null ? ThunderCommunity.fromLemmyCommunityView(json['community']) : null, - 'post': json['post'] != null ? ThunderPost.fromLemmyPostView(json['post']) : null, - 'comment': json['comment'] != null ? ThunderComment.fromLemmyCommentView(json['comment']) : null, - 'user': json['person'] != null ? ThunderUser.fromLemmyUserView(json['person']) : null, - }; - } - - /// Fetches the unread count for the current user - Future> unreadCount() async { - final json = await _request(HttpMethod.get, '/api/v3/user/unread_count', {}); - return json; - } - - /// Fetches comment replies - Future> getCommentReplies({int? page, int? limit, CommentSortType? sort, bool unread = false}) async { - final body = { - 'page': page, - 'limit': limit, - 'sort': sort?.value, - 'unread_only': unread, - }; - - final json = await _request(HttpMethod.get, '/api/v3/user/replies', body); - return json; - } - - /// Mark comment reply as read - Future markCommentReplyAsRead({required int replyId, required bool read}) async { - final body = {'comment_reply_id': replyId, 'read': read}; - await _request(HttpMethod.post, '/api/v3/comment/mark_as_read', body); - } - - /// Get comment mentions - Future> getCommentMentions({int? page, int? limit, CommentSortType? sort, bool unread = false}) async { - final body = { - 'page': page, - 'limit': limit, - 'sort': sort?.value, - 'unread_only': unread, - }; - - final json = await _request(HttpMethod.get, '/api/v3/user/mention', body); - return json; - } - - /// Mark comment mention as read - Future markCommentMentionAsRead({required int mentionId, required bool read}) async { - final body = {'person_mention_id': mentionId, 'read': read}; - await _request(HttpMethod.post, '/api/v3/user/mention/mark_as_read', body); - } - - /// Fetches any private messages - Future> getPrivateMessages({int? page, int? limit, bool unread = false, int? creatorId}) async { - final body = { - 'page': page, - 'limit': limit, - 'unread_only': unread, - 'creator_id': creatorId, - }; - - final json = await _request(HttpMethod.get, '/api/v3/private_message/list', body); - return json['private_messages'].map((pm) => ThunderPrivateMessage.fromLemmyPrivateMessageView(pm)).toList(); - } - - /// Mark private message as read - Future markPrivateMessageAsRead({required int messageId, required bool read}) async { - final body = {'private_message_id': messageId, 'read': read}; - await _request(HttpMethod.post, '/api/v3/private_message/mark_as_read', body); - } - - /// Marks all notifications as read - Future markAllNotificationsAsRead() async { - await _request(HttpMethod.post, '/api/v3/user/mark_all_as_read', {}); - } - - /// Get a community - Future> getCommunity({int? id, String? name}) async { - final body = {'id': id, 'name': name}; - - final json = await _request(HttpMethod.get, '/api/v3/community', body); - - return { - 'community': ThunderCommunity.fromLemmyCommunityView(json['community_view']), - 'site': json['site'] != null ? ThunderSite.fromLemmySite(json['site']) : null, - 'moderators': json['moderators'].map((cmv) => ThunderUser.fromLemmyUser(cmv['moderator'])).toList(), - 'discussion_languages': json['discussion_languages'], - }; - } - - /// Get a list of communities - Future> getCommunities({ - int? page, - int? limit, - FeedListType? feedListType, - PostSortType? postSortType, - }) async { - final body = { - 'page': page, - 'limit': limit, - 'type_': feedListType?.value, - 'sort': postSortType?.value, - }; - - final json = await _request(HttpMethod.get, '/api/v3/community/list', body); - return json['communities'].map((cv) => ThunderCommunity.fromLemmyCommunityView(cv)).toList(); - } - - /// Subscribe to a community - Future subscribeToCommunity({required int communityId, required bool follow}) async { - final body = {'community_id': communityId, 'follow': follow}; - - final json = await _request(HttpMethod.post, '/api/v3/community/follow', body); - return ThunderCommunity.fromLemmyCommunityView(json['community_view']); - } - - /// Block a community - Future blockCommunity({required int communityId, required bool block}) async { - final body = {'community_id': communityId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/v3/community/block', body); - return ThunderCommunity.fromLemmyCommunityView(json['community_view']); - } - - /// Ban a user from a community - Future banUserFromCommunity({ - required int userId, - required int communityId, - required bool ban, - bool? removeData, - String? reason, - int? expires, - }) async { - final body = {'person_id': userId, 'community_id': communityId, 'reason': reason, 'expires': expires, 'remove_data': removeData, 'ban': ban}; - - final json = await _request(HttpMethod.post, '/api/v3/community/ban_user', body); - return ThunderUser.fromLemmyUserView(json['person_view']); - } - - /// Add a moderator to a community - Future> addModerator({required int userId, required int communityId, required bool added}) async { - final body = {'person_id': userId, 'community_id': communityId, 'added': added}; - - final json = await _request(HttpMethod.post, '/api/v3/community/mod', body); - return json['moderators'].map((cmv) => ThunderUser.fromLemmyUser(cmv['moderator'])).toList(); - } - - /// Get a user - Future> getUser({ - int? userId, - String? username, - PostSortType? sort, - int? page, - int? limit, - bool? saved, - }) async { - final body = { - 'person_id': userId, - 'username': username, - 'sort': sort?.value, - 'page': page, - 'limit': limit, - 'saved_only': saved, - }; - - final json = await _request(HttpMethod.get, '/api/v3/user', body); - - return { - 'user': ThunderUser.fromLemmyUserView(json['person_view']), - 'site': json['site'] != null ? ThunderSite.fromLemmySite(json['site']) : null, - 'posts': json['posts'].map((pv) => ThunderPost.fromLemmyPostView(pv)).toList(), - 'comments': json['comments'].map((cv) => ThunderComment.fromLemmyCommentView(cv)).toList(), - 'moderates': json['moderates'].map((cmv) => ThunderCommunity.fromLemmyCommunity(cmv['community'])).toList(), - }; - } - - /// Block a user - Future blockUser({required int userId, required bool block}) async { - final body = {'person_id': userId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/v3/user/block', body); - return ThunderUser.fromLemmyUserView(json['person_view']); - } - - /// Block an instance - Future blockInstance({required int instanceId, required bool block}) async { - final body = {'instance_id': instanceId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/v3/site/block', body); - return json['blocked']; - } - - /// Get federated instances - Future> federated() async { - final json = await _request(HttpMethod.get, '/api/v3/federated_instances', {}); - return json; - } - - /// Upload an image using multipart form data - Future> uploadImage(String filePath) async { - try { - final request = MultipartRequest('POST', Uri.https(account.instance, '/pictrs/image')); - request.headers.addAll(_buildHeaders()); - request.files.add(await MultipartFile.fromPath('images[]', filePath)); - - final response = await request.send(); - if (response.statusCode != 201) throw Exception('Failed to upload image: ${response.statusCode} ${response.reasonPhrase}'); - - final json = await compute(jsonDecode, await response.stream.bytesToString()); - return json; - } catch (e) { - throw Exception('Failed to upload image: $e'); - } - } - - /// Delete an image - Future deleteImage({required String file, required String token}) async { - await _request(HttpMethod.get, '/pictrs/image/delete/$token/$file', {}); - } - - /// Get modlog - Future> getModlog({ - int? page, - int? limit, - ModlogActionType? modlogActionType, - int? communityId, - int? userId, - int? moderatorId, - int? commentId, - }) async { - final body = { - 'page': page, - 'type_': modlogActionType?.value, - 'community_id': communityId, - 'other_person_id': userId, - 'mod_person_id': moderatorId, - 'comment_id': commentId, - }; - - final json = await _request(HttpMethod.get, '/api/v3/modlog', body); - return json; - } -} diff --git a/lib/src/core/network/piefed/piefed_api_client.dart b/lib/src/core/network/piefed/piefed_api_client.dart new file mode 100644 index 000000000..3353ab186 --- /dev/null +++ b/lib/src/core/network/piefed/piefed_api_client.dart @@ -0,0 +1,811 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/core/enums/feed_list_type.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/core/models/thunder_comment_report.dart'; +import 'package:thunder/src/core/models/thunder_post_report.dart'; +import 'package:thunder/src/core/models/thunder_private_message.dart'; +import 'package:thunder/src/core/models/thunder_site.dart'; +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/base_api_client.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// PieFed API client for the `/api/alpha` endpoints. +class PiefedApiClient extends BaseApiClient implements ThunderApiClient { + PiefedApiClient({ + required super.account, + super.debug, + required super.version, + super.httpClient, + }); + + @override + String get basePath => '/api/alpha'; + + @override + String get platformName => 'PieFed'; + + // ============================================================= + // Feature Flags - PieFed has limited support + // ============================================================= + + @override + bool get supportsHidePosts => false; + + @override + bool get supportsPostReports => false; + + @override + bool get supportsCommentReports => false; + + @override + bool get supportsPrivateMessages => false; + + @override + bool get supportsModlog => false; + + @override + bool get supportsSettingsImportExport => false; + + @override + bool get supportsMedia => false; + + @override + bool get supportsTOTP => false; + + @override + bool get supportsInstanceBlock => false; + + // ============================================================= + // Authentication & Site + // ============================================================= + + @override + Future login({required String username, required String password, String? totp}) async { + // PieFed doesn't support TOTP + if (totp != null) { + throw UnsupportedFeatureException('TOTP authentication', platformName: platformName); + } + + final json = await request(HttpMethod.post, '$basePath/user/login', { + 'username_or_email': username, + 'password': password, + }); + return json['jwt'] as String?; + } + + @override + Future site() async { + final json = await request(HttpMethod.get, '$basePath/site', {}); + return ThunderSiteResponse.fromPiefedSiteResponse(json); + } + + // ============================================================= + // Posts + // ============================================================= + + @override + Future getPost(int postId, {int? commentId}) async { + final json = await request(HttpMethod.get, '$basePath/post', { + 'id': postId, + 'comment_id': commentId, + }); + + final post = ThunderPost.fromPiefedPostView(json['post_view']); + final posts = await parsePosts([post]); + final moderators = (json['moderators'] as List).map((mu) => ThunderUser.fromPiefedUser(mu['moderator'])).toList(); + + return ( + post: posts.first, + moderators: moderators, + crossPosts: [], // PieFed doesn't return cross posts + ); + } + + @override + Future getPosts({ + String? cursor, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + int? communityId, + String? communityName, + bool? showHidden, + bool? showSaved, + }) async { + final page = cursor != null ? int.tryParse(cursor) ?? 1 : 1; + + final Map queryParams = { + 'type_': feedListType?.value, + 'sort': postSortType?.value, + 'page': page, + 'limit': limit, + 'community_name': communityName, + 'community_id': communityId, + }; + + if (showSaved == true) queryParams['saved_only'] = showSaved; + + final json = await request(HttpMethod.get, '$basePath/post/list', queryParams); + + final posts = (json['posts'] as List).map((pv) => ThunderPost.fromPiefedPostView(pv)).toList(); + final nextPage = posts.isNotEmpty ? (page + 1).toString() : null; + + return (posts: posts, nextPage: nextPage); + } + + @override + Future createPost({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + }) async { + final json = await request(HttpMethod.post, '$basePath/post', { + 'name': title, + 'community_id': communityId, + 'url': url, + 'body': contents, + 'nsfw': nsfw, + 'language_id': languageId, + }); + return ThunderPost.fromPiefedPostView(json['post_view']); + } + + @override + Future editPost({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + }) async { + final json = await request(HttpMethod.put, '$basePath/post', { + 'post_id': postId, + 'name': title, + 'url': url, + 'body': contents, + 'nsfw': nsfw, + 'language_id': languageId, + }); + return ThunderPost.fromPiefedPostView(json['post_view']); + } + + @override + Future votePost({required int postId, required int score}) async { + final json = await request(HttpMethod.post, '$basePath/post/like', { + 'post_id': postId, + 'score': score, + }); + return ThunderPost.fromPiefedPostView(json['post_view']); + } + + @override + Future savePost({required int postId, required bool save}) async { + final json = await request(HttpMethod.put, '$basePath/post/save', { + 'post_id': postId, + 'save': save, + }); + return ThunderPost.fromPiefedPostView(json['post_view']); + } + + @override + Future readPost({required List postIds, required bool read}) async { + for (final postId in postIds) { + await request(HttpMethod.post, '$basePath/post/mark_as_read', { + 'post_id': postId, + 'read': read, + }); + } + return true; + } + + @override + Future hidePost({required int postId, required bool hide}) { + throw UnsupportedFeatureException('Hiding posts', platformName: platformName); + } + + @override + Future deletePost({required int postId, required bool deleted}) async { + final json = await request(HttpMethod.post, '$basePath/post/delete', { + 'post_id': postId, + 'deleted': deleted, + }); + final post = ThunderPost.fromPiefedPostView(json['post_view']); + return post.deleted == deleted; + } + + @override + Future lockPost({required int postId, required bool locked}) async { + final json = await request(HttpMethod.post, '$basePath/post/lock', { + 'post_id': postId, + 'locked': locked, + }); + final post = ThunderPost.fromPiefedPostView(json['post_view']); + return post.locked == locked; + } + + @override + Future pinPost({required int postId, required bool pinned}) async { + final json = await request(HttpMethod.post, '$basePath/post/feature', { + 'post_id': postId, + 'featured': pinned, + 'feature_type': 'Community', + }); + final post = ThunderPost.fromPiefedPostView(json['post_view']); + return post.featuredCommunity == pinned; + } + + @override + Future removePost({required int postId, required bool removed, required String reason}) async { + final json = await request(HttpMethod.post, '$basePath/post/remove', { + 'post_id': postId, + 'removed': removed, + 'reason': reason, + }); + final post = ThunderPost.fromPiefedPostView(json['post_view']); + return post.removed == removed; + } + + @override + Future reportPost({required int postId, required String reason}) async { + await request(HttpMethod.post, '$basePath/post/report', { + 'post_id': postId, + 'reason': reason, + }); + } + + @override + Future> getPostReports({ + int? postId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) { + throw UnsupportedFeatureException('Post reports', platformName: platformName); + } + + @override + Future resolvePostReport({required int reportId, required bool resolved}) { + throw UnsupportedFeatureException('Post reports', platformName: platformName); + } + + // ============================================================= + // Comments + // ============================================================= + + @override + Future getComment(int commentId) async { + final json = await request(HttpMethod.get, '$basePath/comment', {'id': commentId}); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future getComments({ + required int postId, + int? page, + int? limit, + int? maxDepth, + int? communityId, + int? parentId, + CommentSortType? commentSortType, + }) async { + final json = await request(HttpMethod.get, '$basePath/comment/list', { + 'sort': commentSortType?.value, + 'max_depth': maxDepth, + 'page': page, + 'limit': limit, + 'community_id': communityId, + 'post_id': postId, + 'parent_id': parentId, + }); + + // PieFed returns nested comments, flatten them + final flattenedComments = _flattenComments(json['comments'] as List); + final comments = flattenedComments.map((cv) => ThunderComment.fromPiefedCommentView(cv)).toList(); + final nextPage = (limit != null && comments.length < limit) ? null : (page ?? 0) + 1; + + return (comments: comments, nextPage: nextPage); + } + + /// Flattens nested PieFed comment structure. + List _flattenComments(List comments) { + final flattened = []; + for (final comment in comments) { + flattened.add(comment); + if (comment['replies'] != null && (comment['replies'] as List).isNotEmpty) { + flattened.addAll(_flattenComments(comment['replies'] as List)); + } + } + return flattened; + } + + @override + Future createComment({ + required int postId, + required String content, + int? parentId, + int? languageId, + }) async { + final json = await request(HttpMethod.post, '$basePath/comment', { + 'post_id': postId, + 'content': content, + 'parent_id': parentId, + 'language_id': languageId, + }); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future editComment({ + required int commentId, + required String content, + int? languageId, + }) async { + final json = await request(HttpMethod.put, '$basePath/comment', { + 'comment_id': commentId, + 'content': content, + 'language_id': languageId, + }); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future voteComment({required int commentId, required int score}) async { + final json = await request(HttpMethod.post, '$basePath/comment/like', { + 'comment_id': commentId, + 'score': score, + }); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future saveComment({required int commentId, required bool save}) async { + final json = await request(HttpMethod.put, '$basePath/comment/save', { + 'comment_id': commentId, + 'save': save, + }); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future deleteComment({required int commentId, required bool deleted}) async { + final json = await request(HttpMethod.post, '$basePath/comment/delete', { + 'comment_id': commentId, + 'deleted': deleted, + }); + return ThunderComment.fromPiefedCommentView(json['comment_view']); + } + + @override + Future reportComment({required int commentId, required String reason}) async { + await request(HttpMethod.post, '$basePath/comment/report', { + 'comment_id': commentId, + 'reason': reason, + }); + } + + @override + Future> getCommentReports({ + int? commentId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) { + throw UnsupportedFeatureException('Comment reports', platformName: platformName); + } + + @override + Future resolveCommentReport({required int reportId, required bool resolved}) { + throw UnsupportedFeatureException('Comment reports', platformName: platformName); + } + + // ============================================================= + // Communities + // ============================================================= + + @override + Future getCommunity({int? id, String? name}) async { + final json = await request(HttpMethod.get, '$basePath/community', { + 'id': id, + 'name': name, + }); + + return ( + community: ThunderCommunity.fromPiefedCommunityView(json['community_view']), + site: json['site'] != null ? ThunderSite.fromPiefedSite(json['site']) : null, + moderators: (json['moderators'] as List).map((cmv) => ThunderUser.fromPiefedUser(cmv['moderator'])).toList(), + discussionLanguages: (json['discussion_languages'] as List?)?.cast() ?? [], + ); + } + + @override + Future> getCommunities({ + int? page, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + }) async { + final json = await request(HttpMethod.get, '$basePath/community/list', { + 'page': page, + 'limit': limit, + 'type_': feedListType?.value, + 'sort': postSortType?.value, + }); + return (json['communities'] as List).map((cv) => ThunderCommunity.fromPiefedCommunityView(cv)).toList(); + } + + @override + Future subscribeToCommunity({required int communityId, required bool follow}) async { + final json = await request(HttpMethod.post, '$basePath/community/follow', { + 'community_id': communityId, + 'follow': follow, + }); + // The API response should include the updated subscription status + return ThunderCommunity.fromPiefedCommunityView(json['community_view']); + } + + @override + Future blockCommunity({required int communityId, required bool block}) async { + final json = await request(HttpMethod.post, '$basePath/community/block', { + 'community_id': communityId, + 'block': block, + }); + return ThunderCommunity.fromPiefedCommunityView(json['community_view']); + } + + // ============================================================= + // Users + // ============================================================= + + @override + Future getUser({ + int? userId, + String? username, + PostSortType? sort, + int? page, + int? limit, + bool? saved, + }) async { + final json = await request(HttpMethod.get, '$basePath/user', { + 'person_id': userId, + 'username': username, + 'sort': sort?.value, + 'page': page, + 'limit': limit, + 'saved_only': saved, + }); + + return ( + user: ThunderUser.fromPiefedUserView(json['person_view']), + site: json['site'] != null ? ThunderSite.fromPiefedSite(json['site']) : null, + posts: (json['posts'] as List?)?.map((pv) => ThunderPost.fromPiefedPostView(pv)).toList() ?? [], + comments: (json['comments'] as List?)?.map((cv) => ThunderComment.fromPiefedCommentView(cv)).toList() ?? [], + moderates: (json['moderates'] as List?)?.map((cmv) => ThunderCommunity.fromPiefedCommunity(cmv['community'])).toList() ?? [], + ); + } + + @override + Future blockUser({required int userId, required bool block}) async { + final json = await request(HttpMethod.post, '$basePath/user/block', { + 'person_id': userId, + 'block': block, + }); + return ThunderUser.fromPiefedUserView(json['person_view']); + } + + @override + Future banUserFromCommunity({ + required int userId, + required int communityId, + required bool ban, + bool? removeData, + String? reason, + int? expires, + }) async { + final json = await request(HttpMethod.post, '$basePath/community/ban_user', { + 'person_id': userId, + 'community_id': communityId, + 'ban': ban, + 'remove_data': removeData, + 'reason': reason, + 'expires': expires, + }); + return ThunderUser.fromPiefedUserView(json['person_view']); + } + + @override + Future> addModerator({ + required int userId, + required int communityId, + required bool added, + }) async { + final json = await request(HttpMethod.post, '$basePath/community/mod', { + 'person_id': userId, + 'community_id': communityId, + 'added': added, + }); + return (json['moderators'] as List).map((cmv) => ThunderUser.fromPiefedUser(cmv['moderator'])).toList(); + } + + // ============================================================= + // Search + // ============================================================= + + @override + Future search({ + required String query, + int? communityId, + String? communityName, + int? creatorId, + MetaSearchType? type, + SearchSortType? sort, + FeedListType? listingType, + int? page, + int? limit, + }) async { + final json = await request(HttpMethod.get, '$basePath/search', { + 'q': query, + 'community_id': communityId, + 'community_name': communityName, + 'creator_id': creatorId, + 'type_': type?.searchType, + 'sort': sort?.value, + 'listing_type': listingType?.value, + 'page': page, + 'limit': limit, + }); + + return ( + type: MetaSearchType.values.firstWhere((e) => e.searchType == json['type_']), + posts: (json['posts'] as List?)?.map((pv) => ThunderPost.fromPiefedPostView(pv)).toList() ?? [], + comments: (json['comments'] as List?)?.map((cv) => ThunderComment.fromPiefedCommentView(cv)).toList() ?? [], + communities: (json['communities'] as List?)?.map((cv) => ThunderCommunity.fromPiefedCommunityView(cv)).toList() ?? [], + users: (json['users'] as List?)?.map((pv) => ThunderUser.fromPiefedUserView(pv)).toList() ?? [], + ); + } + + @override + Future resolve({required String query}) async { + final json = await request(HttpMethod.get, '$basePath/resolve_object', {'q': query}); + + return ( + community: json['community'] != null ? ThunderCommunity.fromPiefedCommunityView(json['community']) : null, + post: json['post'] != null ? ThunderPost.fromPiefedPostView(json['post']) : null, + comment: json['comment'] != null ? ThunderComment.fromPiefedCommentView(json['comment']) : null, + user: json['person'] != null ? ThunderUser.fromPiefedUserView(json['person']) : null, + ); + } + + // ============================================================= + // Notifications - Limited support + // ============================================================= + + @override + Future unreadCount() async { + final json = await request(HttpMethod.get, '$basePath/user/unread_count', {}); + return ( + replies: json['replies'] as int? ?? 0, + mentions: json['mentions'] as int? ?? 0, + privateMessages: 0, // PieFed doesn't support private messages + ); + } + + @override + Future> getCommentReplies({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }) async { + final response = await request(HttpMethod.get, '$basePath/user/replies', { + 'page': page, + 'limit': limit, + 'sort': sort?.value, + 'unread_only': unread, + }); + + return (response['replies'] as List).map((crv) { + final comment = ThunderComment.fromPiefedCommentView(crv); + + return comment.copyWith( + recipient: ThunderUser.fromPiefedUser(crv['recipient']), + replyMentionId: crv['comment_reply']['id'], + read: crv['comment_reply']['read'], + ); + }).toList(); + } + + @override + Future markCommentReplyAsRead({required int replyId, required bool read}) async { + await request(HttpMethod.post, '$basePath/comment/mark_as_read', { + 'comment_reply_id': replyId, + 'read': read, + }); + } + + @override + Future> getCommentMentions({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }) async { + final response = await request(HttpMethod.get, '$basePath/user/mention', { + 'page': page, + 'limit': limit, + 'sort': sort?.value, + 'unread_only': unread, + }); + + return (response['replies'] as List).map((mention) { + final comment = ThunderComment.fromPiefedCommentView(mention); + + return comment.copyWith( + recipient: ThunderUser.fromPiefedUser(mention['recipient']), + replyMentionId: mention['comment_reply']['id'], + read: mention['comment_reply']['read'], + ); + }).toList(); + } + + @override + Future markCommentMentionAsRead({required int mentionId, required bool read}) async { + await request(HttpMethod.post, '$basePath/user/mention/mark_as_read', { + 'person_mention_id': mentionId, + 'read': read, + }); + } + + @override + Future markAllNotificationsAsRead() async { + await request(HttpMethod.post, '$basePath/user/mark_all_as_read', {}); + } + + // ============================================================= + // Private Messages - Not supported + // ============================================================= + + @override + Future> getPrivateMessages({ + int? page, + int? limit, + bool unread = false, + int? creatorId, + }) { + throw UnsupportedFeatureException('Private messages', platformName: platformName); + } + + @override + Future markPrivateMessageAsRead({required int messageId, required bool read}) { + throw UnsupportedFeatureException('Private messages', platformName: platformName); + } + + // ============================================================= + // Account Settings - Limited support + // ============================================================= + + @override + Future saveUserSettings({ + String? bio, + String? email, + String? matrixUserId, + String? displayName, + FeedListType? defaultFeedListType, + PostSortType? defaultPostSortType, + bool? showNsfw, + bool? showReadPosts, + bool? showScores, + bool? botAccount, + bool? showBotAccounts, + List? discussionLanguages, + }) async { + await request(HttpMethod.put, '$basePath/user/save_user_settings', { + 'bio': bio, + 'show_nsfw': showNsfw, + 'show_read_posts': showReadPosts, + }); + } + + @override + Future importSettings(String settings) { + throw UnsupportedFeatureException('Settings import', platformName: platformName); + } + + @override + Future exportSettings() { + throw UnsupportedFeatureException('Settings export', platformName: platformName); + } + + @override + Future> media({int? page, int? limit}) { + throw UnsupportedFeatureException('Media management', platformName: platformName); + } + + // ============================================================= + // Modlog - Not supported + // ============================================================= + + @override + Future> getModlog({ + int? page, + int? limit, + ModlogActionType? modlogActionType, + int? communityId, + int? userId, + int? moderatorId, + int? commentId, + }) { + throw UnsupportedFeatureException('Modlog', platformName: platformName); + } + + // ============================================================= + // Instance + // ============================================================= + + @override + Future> federated() async { + return await request(HttpMethod.get, '$basePath/federated_instances', {}); + } + + @override + Future blockInstance({required int instanceId, required bool block}) { + throw UnsupportedFeatureException('Instance blocking', platformName: platformName); + } + + // ============================================================= + // Media - Limited support + // ============================================================= + + @override + Future> uploadImage(String filePath) async { + try { + final uploadRequest = http.MultipartRequest( + 'POST', + Uri.https(account.instance, '/pictrs/image'), + ); + uploadRequest.headers.addAll(buildHeaders()); + uploadRequest.files.add(await http.MultipartFile.fromPath('images[]', filePath)); + + final response = await uploadRequest.send(); + if (response.statusCode != 201) { + throw ApiErrorException( + 'Failed to upload image: ${response.statusCode} ${response.reasonPhrase}', + statusCode: response.statusCode, + platformName: platformName, + ); + } + + final responseBody = await response.stream.bytesToString(); + return jsonDecode(responseBody) as Map; + } catch (e) { + if (e is ApiException) rethrow; + throw ApiErrorException('Failed to upload image: $e', platformName: platformName); + } + } + + @override + Future deleteImage({required String file, required String token}) async { + await request(HttpMethod.get, '/pictrs/image/delete/$token/$file', {}); + } +} diff --git a/lib/src/core/network/piefed_api.dart b/lib/src/core/network/piefed_api.dart deleted file mode 100644 index 0c4e5c319..000000000 --- a/lib/src/core/network/piefed_api.dart +++ /dev/null @@ -1,648 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; - -import 'package:http/http.dart'; -import 'package:version/version.dart'; - -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; - -enum HttpMethod { get, post, put, delete } - -class PiefedApi { - /// The account to use for API calls - final Account account; - - /// Whether to show debug information - final bool debug; - - /// The version of the platform - final Version? version; - - /// The Piefed API client - PiefedApi({required this.account, this.debug = false, required this.version}); - - /// Build headers with optional JWT authorization - Map _buildHeaders() { - final version = getCurrentVersion(removeInternalBuildNumber: true, trimV: true); - final userAgent = 'Thunder/$version'; - - Map headers = { - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }; - - if (account.jwt != null) headers['Authorization'] = 'Bearer ${account.jwt}'; - return headers; - } - - /// Handle response from the request. Throws an exception if the request fails. - Future _handleResponse(Uri uri, Response response) { - if (response.statusCode != 200) { - debugPrint('PieFed API: Failed to make request to $uri: ${response.statusCode} ${response.body}'); - throw Exception(response.body); - } - - return compute(jsonDecode, response.body); - } - - /// Makes an HTTP request with the specified method - Future> _request(HttpMethod method, String endpoint, Map data) async { - try { - final headers = _buildHeaders(); - - Uri uri = Uri.https(account.instance, endpoint); - Response response; - - data.removeWhere((key, value) => value == null); - - if (method == HttpMethod.get) { - // Remove null values and convert values to strings - data = data.map((key, value) => MapEntry(key, value.toString())); - - uri = Uri.https(account.instance, endpoint, data); - if (debug) debugPrint('PieFed API: GET $uri'); - - response = await get(uri, headers: headers); - } else { - uri = Uri.https(account.instance, endpoint); - - switch (method) { - case HttpMethod.post: - if (debug) debugPrint('PieFed API: POST $uri'); - response = await post(uri, body: jsonEncode(data), headers: headers); - break; - case HttpMethod.put: - if (debug) debugPrint('PieFed API: PUT $uri'); - response = await put(uri, body: jsonEncode(data), headers: headers); - break; - default: - throw ArgumentError('Unsupported HTTP method: $method'); - } - } - - return await _handleResponse(uri, response); - } catch (e) { - if (debug) debugPrint('PieFed API: Error: $e'); - rethrow; - } - } - - /// Login - Future login({required String username, required String password}) async { - final body = { - 'username': username, - 'password': password, - }; - - final json = await _request(HttpMethod.post, '/api/alpha/user/login', body); - return json['jwt']; - } - - /// Get site info - Future site() async { - final json = await _request(HttpMethod.get, '/api/alpha/site', {}); - - final siteResponse = ThunderSiteResponse.fromPiefedSiteResponse(json); - return siteResponse; - } - - /// Save user settings - Future saveUserSettings({ - String? bio, - bool? showNsfw, - bool? showReadPosts, - }) async { - final body = { - 'bio': bio, - 'show_nsfw': showNsfw, - 'show_read_posts': showReadPosts, - }; - - await _request(HttpMethod.put, '/api/alpha/user/save_user_settings', body); - } - - /// Fetches a post from the Piefed API - Future> getPost(int postId, {int? commentId}) async { - final queryParams = {'id': postId, 'comment_id': commentId}; - - final json = await _request(HttpMethod.get, '/api/alpha/post', queryParams); - - final posts = await parsePosts([ThunderPost.fromPiefedPostView(json['post_view'])]); - final moderators = json['moderators'].map((mu) => ThunderUser.fromPiefedUser(mu['moderator'])).toList(); - final crossPosts = json['cross_posts'].map((cp) => ThunderPost.fromPiefedPostView(cp)).toList(); - - return { - 'post': posts.first, - 'moderators': moderators, - 'crossPosts': crossPosts, - }; - } - - /// Fetches a list of posts from the Piefed API - Future> getPosts({ - int page = 1, - int? limit, - int? personId, - FeedListType? feedListType, - PostSortType? postSortType, - int? communityId, - String? communityName, - bool? showSaved, - bool? likedOnly, - }) async { - final queryParams = { - 'type_': feedListType?.value, - 'sort': postSortType?.value, - 'page': page.toString(), - 'limit': limit, - 'community_name': communityName, - 'community_id': communityId, - 'person_id': personId, - 'saved_only': showSaved, - 'liked_only': likedOnly, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/post/list', queryParams); - return json['posts'].map((pv) => ThunderPost.fromPiefedPostView(pv)).toList(); - } - - /// Creates a post - Future createPost({ - required String title, - required int communityId, - String? url, - String? contents, - bool? nsfw, - int? languageId, - }) async { - final body = { - 'title': title, - 'community_id': communityId, - 'url': url, - 'body': contents, - 'nsfw': nsfw, - 'language_id': languageId, - }; - - final json = await _request(HttpMethod.post, '/api/alpha/post', body); - return ThunderPost.fromPiefedPostView(json['post_view']); - } - - /// Edits a post - Future editPost({ - required int postId, - required String title, - String? url, - String? contents, - bool? nsfw, - int? languageId, - }) async { - final body = { - 'post_id': postId, - 'title': title, - 'url': url, - 'body': contents, - 'nsfw': nsfw, - 'language_id': languageId, - }; - - final json = await _request(HttpMethod.put, '/api/alpha/post', body); - return ThunderPost.fromPiefedPostView(json['post_view']); - } - - /// Votes on a post - Future votePost({required int postId, required int score}) async { - final body = {'post_id': postId, 'score': score}; - - final json = await _request(HttpMethod.post, '/api/alpha/post/like', body); - return ThunderPost.fromPiefedPostView(json['post_view']); - } - - /// Saves a post - Future savePost({required int postId, required bool save}) async { - final body = {'post_id': postId, 'save': save}; - - final json = await _request(HttpMethod.put, '/api/alpha/post/save', body); - return ThunderPost.fromPiefedPostView(json['post_view']); - } - - /// Marks a set of posts as read - Future readPost({required List postIds, required bool read}) async { - Map body = {'read': read}; - - if (postIds.length > 1) { - body['post_ids'] = postIds; - } else { - body['post_id'] = postIds.first; - } - - final json = await _request(HttpMethod.post, '/api/alpha/post/mark_as_read', body); - return json['success']; - } - - /// Deletes a post - Future deletePost({required int postId, required bool deleted}) async { - final body = {'post_id': postId, 'deleted': deleted}; - - final json = await _request(HttpMethod.post, '/api/alpha/post/delete', body); - final post = ThunderPost.fromPiefedPostView(json['post_view']); - return post.deleted == deleted; - } - - /// Locks a post - Future lockPost({required int postId, required bool locked}) async { - final body = {'post_id': postId, 'locked': locked}; - - final json = await _request(HttpMethod.post, '/api/alpha/post/lock', body); - final post = ThunderPost.fromPiefedPostView(json['post_view']); - return post.locked == locked; - } - - /// Pins a post to the community - Future pinPost({required int postId, required bool pinned}) async { - final body = {'post_id': postId, 'featured': pinned, 'feature_type': 'Community'}; - - final json = await _request(HttpMethod.post, '/api/alpha/post/feature', body); - final post = ThunderPost.fromPiefedPostView(json['post_view']); - return post.featuredCommunity == pinned; - } - - /// Removes a post - Future removePost({required int postId, required bool removed, required String reason}) async { - final body = {'post_id': postId, 'removed': removed, 'reason': reason}; - - final json = await _request(HttpMethod.post, '/api/alpha/post/remove', body); - final post = ThunderPost.fromPiefedPostView(json['post_view']); - return post.removed == removed; - } - - /// Reports a post - Future reportPost({required int postId, required String reason}) async { - final body = {'post_id': postId, 'reason': reason}; - - await _request(HttpMethod.post, '/api/alpha/post/report', body); - } - - /// Fetches a comment from the Piefed API - Future getComment(int commentId) async { - final queryParams = {'id': commentId}; - - final json = await _request(HttpMethod.get, '/api/alpha/comment', queryParams); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Fetches a list of comments from the Piefed API - Future> getComments({ - required int postId, - String? cursor, - int? limit, - int? maxDepth, - int? communityId, - int? parentId, - CommentSortType? commentSortType, - }) async { - Map body = { - 'sort': commentSortType?.value, - 'max_depth': maxDepth, - 'page': cursor, - 'limit': limit, - 'post_id': postId, - 'parent_id': parentId, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/post/replies', body); - - ThunderPost? post; - ThunderCommunity? community; - - List> flattenedComments = []; - - // Flatten the json response. Each comment has a "replies" key that contains the replies to the comment and so forth. - // We should fetch all the comments an their associated replies. - void flattenComments(List comments) { - // Fill in the post/community as they're not included in the comment's replies - for (final comment in comments) { - if (post == null && comment['post'] != null) post = ThunderPost.fromPiefedPost(comment['post']); - - if (community == null && comment['community'] != null) { - final subscribed = comment['subscribed'] != null ? SubscriptionStatus.values.firstWhere((e) => e.name == comment['subscribed']) : null; - community = ThunderCommunity.fromPiefedCommunity(comment['community'], subscribed: subscribed); - } - - flattenedComments.add(comment); - - if (comment['replies'] != null && comment['replies'].isNotEmpty) { - flattenComments(comment['replies']); - } - } - } - - flattenComments(json['comments']); - final comments = flattenedComments.map((cv) => ThunderComment.fromPiefedCommentView(cv, post: post, community: community)).toList(); - - return { - 'comments': comments, - 'next_page': json['next_page'], - }; - } - - /// Creates a comment - Future createComment({required int postId, required String content, int? parentId, int? languageId}) async { - final body = { - 'post_id': postId, - 'body': content, - 'parent_id': parentId, - 'language_id': languageId, - }; - - final json = await _request(HttpMethod.post, '/api/alpha/comment', body); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Edits a comment - Future editComment({required int commentId, required String content, int? languageId}) async { - final body = {'comment_id': commentId, 'body': content, 'language_id': languageId}; - - final json = await _request(HttpMethod.put, '/api/alpha/comment', body); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Votes on a comment - Future voteComment({required int commentId, required int score}) async { - final body = {'comment_id': commentId, 'score': score}; - - final json = await _request(HttpMethod.post, '/api/alpha/comment/like', body); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Saves a comment - Future saveComment({required int commentId, required bool save}) async { - final body = {'comment_id': commentId, 'save': save}; - - final json = await _request(HttpMethod.put, '/api/alpha/comment/save', body); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Deletes a comment - Future deleteComment({required int commentId, required bool deleted}) async { - final body = {'comment_id': commentId, 'deleted': deleted}; - - final json = await _request(HttpMethod.post, '/api/alpha/comment/delete', body); - return ThunderComment.fromPiefedCommentView(json['comment_view']); - } - - /// Reports a comment - Future reportComment({required int commentId, required String reason}) async { - final body = {'comment_id': commentId, 'reason': reason}; - - await _request(HttpMethod.post, '/api/alpha/comment/report', body); - } - - /// Searches for posts, comments, communities, and users - Future> search({ - required String query, - MetaSearchType? type, - SearchSortType? sort, - FeedListType? listingType, - int? page, - int? limit, - }) async { - final body = { - 'q': query, - 'type_': type?.searchType, - 'sort': sort?.value, - 'listing_type': listingType?.value, - 'page': page, - 'limit': limit, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/search', body); - return json; - } - - /// Resolves a given query - Future> resolve({required String query}) async { - final body = {'q': query}; - final json = await _request(HttpMethod.get, '/api/alpha/resolve_object', body); - - return { - 'community': json['community'] != null ? ThunderCommunity.fromPiefedCommunityView(json['community']) : null, - 'post': json['post'] != null ? ThunderPost.fromPiefedPostView(json['post']) : null, - 'comment': json['comment'] != null ? ThunderComment.fromPiefedCommentView(json['comment']) : null, - 'user': json['user'] != null ? ThunderUser.fromPiefedUserView(json['user']) : null, - }; - } - - /// Fetches the unread count for the current user - Future> unreadCount() async { - final json = await _request(HttpMethod.get, '/api/alpha/user/unread_count', {}); - return json; - } - - /// Fetches comment replies - Future> getCommentReplies({int? page, int? limit, CommentSortType? sort, bool unread = false}) async { - final body = { - 'page': page, - 'limit': limit, - 'sort': sort?.value, - 'unread_only': unread, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/user/replies', body); - return json; - } - - /// Mark comment reply as read - Future markCommentReplyAsRead({required int replyId, required bool read}) async { - final body = {'comment_reply_id': replyId, 'read': read}; - await _request(HttpMethod.post, '/api/alpha/comment/mark_as_read', body); - } - - /// Get comment mentions - Future> getCommentMentions({int? page, int? limit, CommentSortType? sort, bool unread = false}) async { - final body = { - 'page': page, - 'limit': limit, - 'sort': sort?.value, - 'unread_only': unread, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/user/mentions', body); - return json; - } - - /// Mark private message as read - Future markPrivateMessageAsRead({required int messageId, required bool read}) async { - final body = {'private_message_id': messageId, 'read': read}; - await _request(HttpMethod.post, '/api/alpha/private_message/mark_as_read', body); - } - - /// Marks all notifications as read - Future markAllNotificationsAsRead() async { - await _request(HttpMethod.post, '/api/alpha/user/mark_all_as_read', {}); - } - - /// Get a community - Future> getCommunity({int? id, String? name}) async { - final body = {'id': id, 'name': name}; - - final json = await _request(HttpMethod.get, '/api/alpha/community', body); - - return { - 'community': ThunderCommunity.fromPiefedCommunityView(json['community_view']), - 'site': json['site'] != null ? ThunderSite.fromPiefedSite(json['site']) : null, - 'moderators': json['moderators'].map((cmv) => ThunderUser.fromPiefedUser(cmv['moderator'])).toList(), - 'discussion_languages': json['discussion_languages'], - }; - } - - /// Get a list of communities - Future> getCommunities({ - int? page, - int? limit, - FeedListType? feedListType, - PostSortType? postSortType, - }) async { - final body = { - 'page': page, - 'limit': limit, - 'type_': feedListType?.value, - 'sort': postSortType?.value, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/community/list', body); - return json['communities'].map((cv) => ThunderCommunity.fromPiefedCommunityView(cv)).toList(); - } - - /// Subscribe to a community - Future subscribeToCommunity({required int communityId, required bool follow}) async { - final body = {'community_id': communityId, 'follow': follow}; - - final json = await _request(HttpMethod.post, '/api/alpha/community/follow', body); - return ThunderCommunity.fromPiefedCommunityView(json['community_view']); - } - - /// Block a community - Future blockCommunity({required int communityId, required bool block}) async { - final body = {'community_id': communityId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/alpha/community/block', body); - return ThunderCommunity.fromPiefedCommunityView(json['community_view']); - } - - /// Ban a user from a community - Future banUserFromCommunity({ - required int userId, - required int communityId, - required bool ban, - String? reason, - int? expires, - }) async { - // If the version is before 1.2.0, use the old key - final isNewVersion = version != null && Version(version!.major, version!.minor, version!.patch).compareTo(Version(1, 2, 0)) >= 0; - - if (ban) { - Map body = {'user_id': userId, 'community_id': communityId, 'reason': reason}; - - // TODO: Remove check once most instances have updated to 1.2.0 - if (isNewVersion) { - body['expired_at'] = expires; - } else { - body['expiredAt'] = expires; - } - - final json = await _request(HttpMethod.post, '/api/alpha/community/moderate/ban', body); - return ThunderUser.fromPiefedUser(isNewVersion ? json['banned_user'] : json['bannedUser']); - } else { - final body = {'user_id': userId, 'community_id': communityId}; - - final json = await _request(HttpMethod.put, '/api/alpha/community/moderate/unban', body); - return ThunderUser.fromPiefedUser(isNewVersion ? json['banned_user'] : json['bannedUser']); - } - } - - /// Add a moderator to a community - Future> addModerator({required int userId, required int communityId, required bool added}) async { - final body = {'person_id': userId, 'community_id': communityId, 'added': added}; - - final json = await _request(HttpMethod.post, '/api/alpha/community/mod', body); - return json['moderators'].map((cmv) => ThunderUser.fromPiefedUser(cmv['moderator'])).toList(); - } - - /// Get a user - Future> getUser({ - int? userId, - String? username, - PostSortType? sort, - int? page, - int? limit, - bool? saved, - }) async { - final body = { - 'person_id': userId, - 'username': username, - 'sort': sort?.value, - 'page': page, - 'limit': limit, - 'saved_only': saved, - 'include_content': true, - }; - - final json = await _request(HttpMethod.get, '/api/alpha/user', body); - - return { - 'user': ThunderUser.fromPiefedUserView(json['person_view']), - 'site': json['site'] != null ? ThunderSite.fromPiefedSite(json['site']) : null, - 'posts': json['posts'].map((pv) => ThunderPost.fromPiefedPostView(pv)).toList(), - 'comments': json['comments'].map((cv) => ThunderComment.fromPiefedCommentView(cv)).toList(), - 'moderates': json['moderates'].map((cmv) => ThunderCommunity.fromPiefedCommunity(cmv['community'])).toList(), - }; - } - - /// Block a user - Future blockUser({required int userId, required bool block}) async { - final body = {'person_id': userId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/alpha/user/block', body); - return ThunderUser.fromPiefedUser(json['person_view']); - } - - /// Block an instance - Future blockInstance({required int instanceId, required bool block}) async { - final body = {'instance_id': instanceId, 'block': block}; - - final json = await _request(HttpMethod.post, '/api/alpha/site/block', body); - return json['blocked']; - } - - /// Upload an image using multipart form data - Future uploadImage(String filePath) async { - try { - final request = MultipartRequest('POST', Uri.https(account.instance, '/api/alpha/upload/image')); - request.headers.addAll(_buildHeaders()); - request.files.add(await MultipartFile.fromPath('file', filePath)); - - final response = await request.send(); - if (response.statusCode != 200) throw Exception('Failed to upload image: ${response.statusCode} ${response.reasonPhrase}'); - - final json = await compute(jsonDecode, await response.stream.bytesToString()); - return json['url']; - } catch (e) { - throw Exception('Failed to upload image: $e'); - } - } -} diff --git a/lib/src/core/network/thunder_api_client.dart b/lib/src/core/network/thunder_api_client.dart new file mode 100644 index 000000000..07783dd88 --- /dev/null +++ b/lib/src/core/network/thunder_api_client.dart @@ -0,0 +1,462 @@ +import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/core/enums/feed_list_type.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/core/models/thunder_comment_report.dart'; +import 'package:thunder/src/core/models/thunder_post_report.dart'; +import 'package:thunder/src/core/models/thunder_private_message.dart'; +import 'package:thunder/src/core/models/thunder_site.dart'; +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Response from getting a single post. +typedef GetPostResponse = ({ + ThunderPost post, + List moderators, + List crossPosts, +}); + +/// Response from getting a list of posts. +typedef GetPostsResponse = ({ + List posts, + String? nextPage, +}); + +/// Response from getting a list of comments. +typedef GetCommentsResponse = ({ + List comments, + int? nextPage, +}); + +/// Response from getting a community. +typedef GetCommunityResponse = ({ + ThunderCommunity community, + ThunderSite? site, + List moderators, + List discussionLanguages, +}); + +/// Response from getting a user. +typedef GetUserResponse = ({ + ThunderUser user, + ThunderSite? site, + List posts, + List comments, + List moderates, +}); + +/// Response from resolving an object. +typedef ResolveResponse = ({ + ThunderCommunity? community, + ThunderPost? post, + ThunderComment? comment, + ThunderUser? user, +}); + +/// Response from getting unread count. +typedef UnreadCountResponse = ({ + int replies, + int mentions, + int privateMessages, +}); + +/// Response from searching. +typedef SearchResponse = ({ + MetaSearchType type, + List posts, + List comments, + List communities, + List users, +}); + +/// Abstract interface defining all API operations. +/// +/// All platform-specific clients must implement this interface. +abstract class ThunderApiClient { + // ============================================================= + // Platform Identification + // ============================================================= + + /// The platform name (e.g., 'Lemmy', 'PieFed') for error messages and logging. + String get platformName; + + // ============================================================= + // Authentication & Site + // ============================================================= + + /// Login to the instance. + /// + /// Returns the JWT token on success, or null if login failed. + Future login({required String username, required String password, String? totp}); + + /// Get instance information and current user data (on specific API versions). + Future site(); + + // ============================================================= + // Posts + // ============================================================= + + /// Fetch a single post by ID. + Future getPost(int postId, {int? commentId}); + + /// Fetch a list of posts. + Future getPosts({ + String? cursor, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + int? communityId, + String? communityName, + bool? showHidden, + bool? showSaved, + }); + + /// Create a new post. + Future createPost({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + }); + + /// Edit an existing post. + Future editPost({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + }); + + /// Vote on a post. + Future votePost({required int postId, required int score}); + + /// Save or unsave a post. + Future savePost({required int postId, required bool save}); + + /// Mark posts as read/unread. + Future readPost({required List postIds, required bool read}); + + /// Hide or unhide a post. + Future hidePost({required int postId, required bool hide}); + + /// Delete or restore a post. + Future deletePost({required int postId, required bool deleted}); + + /// Lock or unlock a post. + Future lockPost({required int postId, required bool locked}); + + /// Pin or unpin a post to a community. + Future pinPost({required int postId, required bool pinned}); + + /// Remove a post (moderator action). + Future removePost({required int postId, required bool removed, required String reason}); + + /// Report a post. + Future reportPost({required int postId, required String reason}); + + /// Get post reports. + Future> getPostReports({ + int? postId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }); + + /// Resolve a post report. + Future resolvePostReport({required int reportId, required bool resolved}); + + // ============================================================= + // Comments + // ============================================================= + + /// Fetch a single comment by ID. + Future getComment(int commentId); + + /// Fetch comments for a post. + Future getComments({ + required int postId, + int? page, + int? limit, + int? maxDepth, + int? communityId, + int? parentId, + CommentSortType? commentSortType, + }); + + /// Create a new comment. + Future createComment({ + required int postId, + required String content, + int? parentId, + int? languageId, + }); + + /// Edit an existing comment. + Future editComment({ + required int commentId, + required String content, + int? languageId, + }); + + /// Vote on a comment. + Future voteComment({required int commentId, required int score}); + + /// Save or unsave a comment. + Future saveComment({required int commentId, required bool save}); + + /// Delete or restore a comment. + Future deleteComment({required int commentId, required bool deleted}); + + /// Report a comment. + Future reportComment({required int commentId, required String reason}); + + /// Get comment reports. + Future> getCommentReports({ + int? commentId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }); + + /// Resolve a comment report. + Future resolveCommentReport({required int reportId, required bool resolved}); + + // ============================================================= + // Communities + // ============================================================= + + /// Fetch a single community. + Future getCommunity({int? id, String? name}); + + /// Fetch a list of communities. + Future> getCommunities({ + int? page, + int? limit, + FeedListType? feedListType, + PostSortType? postSortType, + }); + + /// Subscribe or unsubscribe from a community. + Future subscribeToCommunity({required int communityId, required bool follow}); + + /// Block or unblock a community. + Future blockCommunity({required int communityId, required bool block}); + + // ============================================================= + // Users + // ============================================================= + + /// Fetch a user profile and their content. + Future getUser({ + int? userId, + String? username, + PostSortType? sort, + int? page, + int? limit, + bool? saved, + }); + + /// Block or unblock a user. + Future blockUser({required int userId, required bool block}); + + /// Ban a user from a community. + Future banUserFromCommunity({ + required int userId, + required int communityId, + required bool ban, + bool? removeData, + String? reason, + int? expires, + }); + + /// Add or remove a moderator from a community. + Future> addModerator({ + required int userId, + required int communityId, + required bool added, + }); + + // ============================================================= + // Search + // ============================================================= + + /// Search for content. + Future search({ + required String query, + int? communityId, + String? communityName, + int? creatorId, + MetaSearchType? type, + SearchSortType? sort, + FeedListType? listingType, + int? page, + int? limit, + }); + + /// Resolve an ActivityPub URL or Webfinger address. + Future resolve({required String query}); + + // ============================================================= + // Notifications + // ============================================================= + + /// Get unread notification counts. + Future unreadCount(); + + /// Get comment replies. + Future> getCommentReplies({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }); + + /// Mark a comment reply as read. + Future markCommentReplyAsRead({required int replyId, required bool read}); + + /// Get comment mentions. + Future> getCommentMentions({ + int? page, + int? limit, + CommentSortType? sort, + bool unread = false, + }); + + /// Mark a comment mention as read. + Future markCommentMentionAsRead({required int mentionId, required bool read}); + + /// Mark all notifications as read. + Future markAllNotificationsAsRead(); + + // ============================================================= + // Private Messages + // ============================================================= + + /// Get private messages. + Future> getPrivateMessages({ + int? page, + int? limit, + bool unread = false, + int? creatorId, + }); + + /// Mark a private message as read. + Future markPrivateMessageAsRead({required int messageId, required bool read}); + + // ============================================================= + // Account Settings + // ============================================================= + + /// Save user settings. + Future saveUserSettings({ + String? bio, + String? email, + String? matrixUserId, + String? displayName, + FeedListType? defaultFeedListType, + PostSortType? defaultPostSortType, + bool? showNsfw, + bool? showReadPosts, + bool? showScores, + bool? botAccount, + bool? showBotAccounts, + List? discussionLanguages, + }); + + /// Import settings from a backup. + Future importSettings(String settings); + + /// Export settings. + Future exportSettings(); + + /// Get user's uploaded media. + Future> media({int? page, int? limit}); + + // ============================================================= + // Modlog + // ============================================================= + + /// Get modlog entries. + Future> getModlog({ + int? page, + int? limit, + ModlogActionType? modlogActionType, + int? communityId, + int? userId, + int? moderatorId, + int? commentId, + }); + + // ============================================================= + // Instance + // ============================================================= + + /// Get federated instances. + Future> federated(); + + /// Block or unblock an instance. + Future blockInstance({required int instanceId, required bool block}); + + // ============================================================= + // Media + // ============================================================= + + /// Upload an image. + Future> uploadImage(String filePath); + + /// Delete an uploaded image. + Future deleteImage({required String file, required String token}); + + // ============================================================= + // Feature Flags + // ============================================================= + + /// Whether the platform supports hiding posts. + bool get supportsHidePosts => true; + + /// Whether the platform supports post reports. + bool get supportsPostReports => true; + + /// Whether the platform supports comment reports. + bool get supportsCommentReports => true; + + /// Whether the platform supports private messages. + bool get supportsPrivateMessages => true; + + /// Whether the platform supports modlog. + bool get supportsModlog => true; + + /// Whether the platform supports settings import/export. + bool get supportsSettingsImportExport => true; + + /// Whether the platform supports media management. + bool get supportsMedia => true; + + /// Whether the platform supports TOTP for login. + bool get supportsTOTP => true; + + /// Whether the platform supports instance blocking. + bool get supportsInstanceBlock => true; + + // ============================================================= + // Lifecycle + // ============================================================= + + /// Clean up resources. + void dispose(); +} diff --git a/lib/src/features/account/data/repositories/account_repository_impl.dart b/lib/src/features/account/data/repositories/account_repository_impl.dart index 140fd13fa..1c134974e 100644 --- a/lib/src/features/account/data/repositories/account_repository_impl.dart +++ b/lib/src/features/account/data/repositories/account_repository_impl.dart @@ -2,52 +2,31 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; /// Implementation of [AccountRepository] class AccountRepositoryImpl implements AccountRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository - late LemmyApi lemmy; - - /// The Piefed client to use for the repository - late PiefedApi piefed; - - AccountRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + final Account account; + + /// The API client to use for the repository + final ThunderApiClient _api; + + /// Creates a new AccountRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + AccountRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future login({required String username, required String password, String? totp}) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.login(username: username, password: password, totp: totp); - case ThreadiversePlatform.piefed: - return await piefed.login(username: username, password: password); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.login(username: username, password: password, totp: totp); } @override @@ -55,16 +34,8 @@ class AccountRepositoryImpl implements AccountRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.site(); - return response.myUser?.follows ?? []; - case ThreadiversePlatform.piefed: - final response = await piefed.site(); - return response.myUser?.follows ?? []; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.site(); + return response.myUser?.follows ?? []; } @override @@ -72,14 +43,11 @@ class AccountRepositoryImpl implements AccountRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.media(page: page, limit: limit); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsMedia) { + throw UnsupportedFeatureException('Media management', platformName: _api.platformName); } + + return await _api.media(page: page, limit: limit); } @override @@ -100,31 +68,20 @@ class AccountRepositoryImpl implements AccountRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.saveUserSettings( - bio: bio, - email: email, - matrixUserId: matrixUserId, - displayName: displayName, - defaultFeedListType: defaultFeedListType, - defaultPostSortType: defaultPostSortType, - showNsfw: showNsfw, - showReadPosts: showReadPosts, - showScores: showScores, - botAccount: botAccount, - showBotAccounts: showBotAccounts, - discussionLanguages: discussionLanguages, - ); - case ThreadiversePlatform.piefed: - await piefed.saveUserSettings( - bio: bio, - showNsfw: showNsfw, - showReadPosts: showReadPosts, - ); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.saveUserSettings( + bio: bio, + email: email, + matrixUserId: matrixUserId, + displayName: displayName, + defaultFeedListType: defaultFeedListType, + defaultPostSortType: defaultPostSortType, + showNsfw: showNsfw, + showReadPosts: showReadPosts, + showScores: showScores, + botAccount: botAccount, + showBotAccounts: showBotAccounts, + discussionLanguages: discussionLanguages, + ); } @override @@ -132,14 +89,11 @@ class AccountRepositoryImpl implements AccountRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.importSettings(settings); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsSettingsImportExport) { + throw UnsupportedFeatureException('Settings import', platformName: _api.platformName); } + + return await _api.importSettings(settings); } @override @@ -147,13 +101,37 @@ class AccountRepositoryImpl implements AccountRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.exportSettings(); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsSettingsImportExport) { + throw UnsupportedFeatureException('Settings export', platformName: _api.platformName); } + + return await _api.exportSettings(); + } + + @override + Future uploadImage(String filePath) async { + final l10n = GlobalContext.l10n; + if (account.anonymous) throw Exception(l10n.userNotLoggedIn); + + final response = await _api.uploadImage(filePath); + + if (response['files'] != null && (response['files'] as List).isNotEmpty) { + final filename = response['files'][0]['file']; + return "https://${account.instance}/pictrs/image/$filename"; + } + + throw ApiErrorException('Failed to upload image: Invalid response', platformName: 'Unknown'); + } + + @override + Future deleteImage({required String file, required String token}) async { + final l10n = GlobalContext.l10n; + if (account.anonymous) throw Exception(l10n.userNotLoggedIn); + + if (!_api.supportsMedia) { + throw UnsupportedFeatureException('Media management'); + } + + await _api.deleteImage(file: file, token: token); } } diff --git a/lib/src/features/account/domain/repositories/account_repository.dart b/lib/src/features/account/domain/repositories/account_repository.dart index f075782cf..f715b7982 100644 --- a/lib/src/features/account/domain/repositories/account_repository.dart +++ b/lib/src/features/account/domain/repositories/account_repository.dart @@ -34,4 +34,10 @@ abstract class AccountRepository { /// Exports the user's settings. Future exportSettings(); + + /// Upload an image. + Future uploadImage(String filePath); + + /// Delete an uploaded image. + Future deleteImage({required String file, required String token}); } diff --git a/lib/src/features/account/presentation/bloc/profile_bloc.dart b/lib/src/features/account/presentation/bloc/profile_bloc.dart index 717fa0b9a..8d1103313 100644 --- a/lib/src/features/account/presentation/bloc/profile_bloc.dart +++ b/lib/src/features/account/presentation/bloc/profile_bloc.dart @@ -163,7 +163,7 @@ class ProfileBloc extends Bloc { return await _initializeAuth(InitializeAuth(), emit); } catch (e) { debugPrint('Error adding profile: ${e.toString()}'); - return emit(state.copyWith(status: ProfileStatus.failure, error: () => e.toString())); + return emit(state.copyWith(status: ProfileStatus.failure, error: () => getExceptionErrorMessage(e))); } } diff --git a/lib/src/features/comment/data/repositories/comment_repository_impl.dart b/lib/src/features/comment/data/repositories/comment_repository_impl.dart index 0c15fd78e..2aaf635d0 100644 --- a/lib/src/features/comment/data/repositories/comment_repository_impl.dart +++ b/lib/src/features/comment/data/repositories/comment_repository_impl.dart @@ -2,53 +2,32 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; /// Implementation of [CommentRepository] class CommentRepositoryImpl implements CommentRepository { /// The account to use for methods invoked in this repository - Account account; + final Account account; - /// The Lemmy client to use for the repository - late LemmyApi lemmy; + /// The API client to use for the repository + final ThunderApiClient _api; - /// The Piefed client to use for the repository - late PiefedApi piefed; - - CommentRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + /// Creates a new CommentRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + CommentRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future getComment(int commentId) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getComment(commentId); - case ThreadiversePlatform.piefed: - return await piefed.getComment(commentId); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.getComment(commentId); } @override @@ -62,31 +41,20 @@ class CommentRepositoryImpl implements CommentRepository { int? limit, int? communityId, }) async { - /// Lemmy uses page while Piefed uses cursor for pagination - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getComments( - postId: postId, - page: page, - limit: limit, - maxDepth: maxDepth, - communityId: communityId, - parentId: parentId, - commentSortType: commentSortType, - ); - case ThreadiversePlatform.piefed: - return await piefed.getComments( - postId: postId, - cursor: cursor, - limit: limit, - maxDepth: maxDepth, - communityId: communityId, - parentId: parentId, - commentSortType: commentSortType, - ); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.getComments( + postId: postId, + page: page, + limit: limit, + maxDepth: maxDepth, + communityId: communityId, + parentId: parentId, + commentSortType: commentSortType, + ); + + return { + 'comments': response.comments, + 'next_page': response.nextPage, + }; } @override @@ -99,24 +67,12 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.createComment( - postId: postId, - content: content, - parentId: parentId, - languageId: languageId, - ); - case ThreadiversePlatform.piefed: - return await piefed.createComment( - postId: postId, - content: content, - parentId: parentId, - languageId: languageId, - ); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.createComment( + postId: postId, + content: content, + parentId: parentId, + languageId: languageId, + ); } @override @@ -128,22 +84,11 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.editComment( - commentId: commentId, - content: content, - languageId: languageId, - ); - case ThreadiversePlatform.piefed: - return await piefed.editComment( - commentId: commentId, - content: content, - languageId: languageId, - ); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.editComment( + commentId: commentId, + content: content, + languageId: languageId, + ); } @override @@ -151,14 +96,7 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.voteComment(commentId: comment.id, score: score); - case ThreadiversePlatform.piefed: - return await piefed.voteComment(commentId: comment.id, score: score); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.voteComment(commentId: comment.id, score: score); } @override @@ -166,14 +104,7 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.saveComment(commentId: comment.id, save: save); - case ThreadiversePlatform.piefed: - return await piefed.saveComment(commentId: comment.id, save: save); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.saveComment(commentId: comment.id, save: save); } @override @@ -181,14 +112,7 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.deleteComment(commentId: comment.id, deleted: deleted); - case ThreadiversePlatform.piefed: - return await piefed.deleteComment(commentId: comment.id, deleted: deleted); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.deleteComment(commentId: comment.id, deleted: deleted); } @override @@ -196,29 +120,31 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.reportComment(commentId: commentId, reason: reason); - case ThreadiversePlatform.piefed: - return await piefed.reportComment(commentId: commentId, reason: reason); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.reportComment(commentId: commentId, reason: reason); } @override - Future> getCommentReports({int? commentId, int page = 1, int limit = 20, bool unresolved = false, int? communityId}) async { + Future> getCommentReports({ + int? commentId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) async { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getCommentReports(commentId: commentId, page: page, limit: limit, unresolved: unresolved, communityId: communityId); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsCommentReports) { + throw UnsupportedFeatureException('Comment reports', platformName: _api.platformName); } + + return await _api.getCommentReports( + commentId: commentId, + page: page, + limit: limit, + unresolved: unresolved, + communityId: communityId, + ); } @override @@ -226,14 +152,11 @@ class CommentRepositoryImpl implements CommentRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.resolveCommentReport(reportId: reportId, resolved: resolved); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsCommentReports) { + throw UnsupportedFeatureException('Comment reports', platformName: _api.platformName); } + + return await _api.resolveCommentReport(reportId: reportId, resolved: resolved); } @override diff --git a/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart b/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart index d389a6c13..db9d6680f 100644 --- a/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart +++ b/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart @@ -3,12 +3,8 @@ import 'package:flutter/foundation.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/shared/utils/error_messages.dart'; import 'package:thunder/src/app/utils/global_context.dart'; @@ -46,23 +42,11 @@ class CreateCommentCubit extends Cubit { emit(state.copyWith(status: CreateCommentStatus.imageUploadInProgress)); try { + final accountRepository = AccountRepositoryImpl(account: account); + for (String imageFile in imageFiles) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final result = await LemmyApi(account: account, debug: kDebugMode, version: version).uploadImage(imageFile); - String url = "https://${account.instance}/pictrs/image/${result['files'][0]['file']}"; - - urls.add(url); - break; - case ThreadiversePlatform.piefed: - final url = await PiefedApi(account: account, debug: kDebugMode, version: version).uploadImage(imageFile); - urls.add(url); - break; - default: - throw Exception(l10n.unexpectedError); - } + final url = await accountRepository.uploadImage(imageFile); + urls.add(url); // Add a delay between each upload to avoid possible rate limiting await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); diff --git a/lib/src/features/community/data/repositories/community_repository_impl.dart b/lib/src/features/community/data/repositories/community_repository_impl.dart index eb6fcba53..3833718a4 100644 --- a/lib/src/features/community/data/repositories/community_repository_impl.dart +++ b/lib/src/features/community/data/repositories/community_repository_impl.dart @@ -2,16 +2,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; /// Interface for a community repository abstract class CommunityRepository { @@ -40,39 +38,25 @@ abstract class CommunityRepository { /// Implementation of [CommunityRepository] class CommunityRepositoryImpl implements CommunityRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository - late LemmyApi client; - - /// The Piefed client to use for the repository - late PiefedApi piefed; - - CommunityRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - client = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + final Account account; + + /// The API client to use for the repository + final ThunderApiClient _api; + + /// Creates a new CommunityRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + CommunityRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future> getCommunity({int? id, String? name}) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.getCommunity(id: id, name: name); - case ThreadiversePlatform.piefed: - return await piefed.getCommunity(id: id, name: name); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.getCommunity(id: id, name: name); + return { + 'community': response.community, + 'site': response.site, + 'moderators': response.moderators, + 'discussion_languages': response.discussionLanguages, + }; } @override @@ -80,14 +64,7 @@ class CommunityRepositoryImpl implements CommunityRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.subscribeToCommunity(communityId: communityId, follow: follow); - case ThreadiversePlatform.piefed: - return await piefed.subscribeToCommunity(communityId: communityId, follow: follow); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.subscribeToCommunity(communityId: communityId, follow: follow); } @override @@ -95,55 +72,54 @@ class CommunityRepositoryImpl implements CommunityRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.blockCommunity(communityId: communityId, block: block); - case ThreadiversePlatform.piefed: - return await piefed.blockCommunity(communityId: communityId, block: block); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.blockCommunity(communityId: communityId, block: block); } @override - Future banUserFromCommunity({required int userId, required bool ban, required int communityId, String? reason, int? expires, bool removeData = false}) async { + Future banUserFromCommunity({ + required int userId, + required bool ban, + required int communityId, + String? reason, + int? expires, + bool removeData = false, + }) async { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.banUserFromCommunity(userId: userId, communityId: communityId, ban: ban, removeData: removeData, reason: reason, expires: expires); - case ThreadiversePlatform.piefed: - return await piefed.banUserFromCommunity(userId: userId, communityId: communityId, ban: ban, reason: reason, expires: expires); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.banUserFromCommunity( + userId: userId, + communityId: communityId, + ban: ban, + removeData: removeData, + reason: reason, + expires: expires, + ); } @override - Future> addModerator({required int userId, required bool added, required int communityId}) async { + Future> addModerator({ + required int userId, + required bool added, + required int communityId, + }) async { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.addModerator(userId: userId, communityId: communityId, added: added); - case ThreadiversePlatform.piefed: - return await piefed.addModerator(userId: userId, communityId: communityId, added: added); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.addModerator( + userId: userId, + communityId: communityId, + added: added, + ); } @override Future> trending() async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.getCommunities(page: 1, limit: 5, feedListType: FeedListType.local, postSortType: PostSortType.active); - case ThreadiversePlatform.piefed: - return await piefed.getCommunities(page: 1, limit: 5, feedListType: FeedListType.local, postSortType: PostSortType.new_); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.getCommunities( + page: 1, + limit: 5, + feedListType: FeedListType.local, + postSortType: PostSortType.active, + ); } } diff --git a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart b/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart index 6707d6fa6..ac0725670 100644 --- a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart +++ b/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart @@ -7,6 +7,8 @@ import 'package:stream_transform/stream_transform.dart'; import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/shared/utils/error_messages.dart'; + part 'anonymous_subscriptions_event.dart'; part 'anonymous_subscriptions_state.dart'; @@ -35,7 +37,7 @@ class AnonymousSubscriptionsBloc extends Bloc { )); } catch (e) { debugPrint('Error fetching feed: $e'); - return emit(state.copyWith(status: FeedStatus.failure, message: e is LemmyApiException ? e.message : e.toString())); + return emit(state.copyWith(status: FeedStatus.failure, message: getExceptionErrorMessage(e))); } } diff --git a/lib/src/features/instance/data/repositories/instance_repository.dart b/lib/src/features/instance/data/repositories/instance_repository.dart index 54c78a8ff..38eaf63e3 100644 --- a/lib/src/features/instance/data/repositories/instance_repository.dart +++ b/lib/src/features/instance/data/repositories/instance_repository.dart @@ -2,13 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/models/thunder_site_response.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/account/account.dart'; /// Interface for a instance repository abstract class InstanceRepository { @@ -25,39 +24,19 @@ abstract class InstanceRepository { /// Implementation of [InstanceRepository] class InstanceRepositoryImpl implements InstanceRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository - late LemmyApi client; + final Account account; - /// The Piefed client to use for the repository - late PiefedApi piefed; + /// The API client to use for the repository + final ThunderApiClient _api; - InstanceRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - client = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + /// Creates a new InstanceRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + InstanceRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future info() async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.site(); - case ThreadiversePlatform.piefed: - return await piefed.site(); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.site(); } @override @@ -65,26 +44,15 @@ class InstanceRepositoryImpl implements InstanceRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.blockInstance(instanceId: instanceId, block: block); - case ThreadiversePlatform.piefed: - return await piefed.blockInstance(instanceId: instanceId, block: block); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsInstanceBlock) { + throw UnsupportedFeatureException('Instance blocking', platformName: _api.platformName); } + + return await _api.blockInstance(instanceId: instanceId, block: block); } @override Future> federated() async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.federated(); - case ThreadiversePlatform.piefed: - // TODO: Implement action on Piefed - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.federated(); } } diff --git a/lib/src/features/modlog/data/repositories/modlog_repository.dart b/lib/src/features/modlog/data/repositories/modlog_repository.dart index da80cb608..95fb78c5a 100644 --- a/lib/src/features/modlog/data/repositories/modlog_repository.dart +++ b/lib/src/features/modlog/data/repositories/modlog_repository.dart @@ -1,15 +1,11 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; /// Model representing a page of modlog events class ModlogFeed { @@ -37,9 +33,18 @@ abstract class ModlogRepository { }); } -/// Implementation of [ModlogRepository] using Lemmy API +/// Implementation of [ModlogRepository] class ModlogRepositoryImpl implements ModlogRepository { - ModlogRepositoryImpl(); + /// The account to use for methods invoked in this repository + final Account account; + + /// The API client to use for the repository + final ThunderApiClient _api; + + /// Creates a new ModlogRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + ModlogRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future getModlogEvents({ @@ -51,239 +56,32 @@ class ModlogRepositoryImpl implements ModlogRepository { int? moderatorId, int? commentId, }) async { - final result = await _fetchModlogEvents( - limit: limit, - page: page, - modlogActionType: modlogActionType, - communityId: communityId, - userId: userId, - moderatorId: moderatorId, - commentId: commentId, - ); - return ModlogFeed( - items: result['modLogEventItems'] as List, - hasReachedEnd: result['hasReachedEnd'] as bool, - currentPage: result['currentPage'] as int, - ); - } -} - -/// Helper function which handles the logic of fetching modlog events from the API -Future> _fetchModlogEvents({ - int limit = 20, - int page = 1, - ModlogActionType? modlogActionType, - int? communityId, - int? userId, - int? moderatorId, - int? commentId, -}) async { - final account = await fetchActiveProfile(); - final version = PlatformVersionCache().get(account.instance); - - bool hasReachedEnd = false; + bool hasReachedEnd = false; + List modLogEventItems = []; + int currentPage = page; + + // Guarantee that we fetch at least x events (unless we reach the end of the feed) + do { + final items = await _api.getModlog( + page: currentPage, + limit: limit, + modlogActionType: modlogActionType, + communityId: communityId, + userId: userId, + moderatorId: moderatorId, + commentId: commentId, + ); - List modLogEventItems = []; + modLogEventItems.addAll(items); - int currentPage = page; + if (items.isEmpty) hasReachedEnd = true; + currentPage++; + } while (!hasReachedEnd && modLogEventItems.length < limit); - // Guarantee that we fetch at least x events (unless we reach the end of the feed) - do { - final response = await LemmyApi(account: account, debug: kDebugMode, version: version).getModlog( - page: currentPage, - modlogActionType: modlogActionType, - communityId: communityId, - userId: userId, - moderatorId: moderatorId, - commentId: commentId, + return ModlogFeed( + items: modLogEventItems, + hasReachedEnd: hasReachedEnd, + currentPage: currentPage, ); - - List items = []; - - // Convert the response to a list of modlog events - List removedPosts = response['removed_posts'].map((e) => parseModlogEvent(ModlogActionType.modRemovePost, e)).toList(); - List lockedPosts = response['locked_posts'].map((e) => parseModlogEvent(ModlogActionType.modLockPost, e)).toList(); - List featuredPosts = response['featured_posts'].map((e) => parseModlogEvent(ModlogActionType.modFeaturePost, e)).toList(); - List removedComments = response['removed_comments'].map((e) => parseModlogEvent(ModlogActionType.modRemoveComment, e)).toList(); - List removedCommunities = response['removed_communities'].map((e) => parseModlogEvent(ModlogActionType.modRemoveCommunity, e)).toList(); - List bannedFromCommunity = response['banned_from_community'].map((e) => parseModlogEvent(ModlogActionType.modBanFromCommunity, e)).toList(); - List banned = response['banned'].map((e) => parseModlogEvent(ModlogActionType.modBan, e)).toList(); - List addedToCommunity = response['added_to_community'].map((e) => parseModlogEvent(ModlogActionType.modAddCommunity, e)).toList(); - List transferredToCommunity = response['transferred_to_community'].map((e) => parseModlogEvent(ModlogActionType.modTransferCommunity, e)).toList(); - List added = response['added'].map((e) => parseModlogEvent(ModlogActionType.modAdd, e)).toList(); - List adminPurgedPersons = response['admin_purged_persons'].map((e) => parseModlogEvent(ModlogActionType.adminPurgePerson, e)).toList(); - List adminPurgedCommunities = response['admin_purged_communities'].map((e) => parseModlogEvent(ModlogActionType.adminPurgeCommunity, e)).toList(); - List adminPurgedPosts = response['admin_purged_posts'].map((e) => parseModlogEvent(ModlogActionType.adminPurgePost, e)).toList(); - List adminPurgedComments = response['admin_purged_comments'].map((e) => parseModlogEvent(ModlogActionType.adminPurgeComment, e)).toList(); - List hiddenCommunities = response['hidden_communities'].map((e) => parseModlogEvent(ModlogActionType.modHideCommunity, e)).toList(); - - items.addAll(removedPosts); - items.addAll(lockedPosts); - items.addAll(featuredPosts); - items.addAll(removedComments); - items.addAll(removedCommunities); - items.addAll(bannedFromCommunity); - items.addAll(banned); - items.addAll(addedToCommunity); - items.addAll(transferredToCommunity); - items.addAll(added); - items.addAll(adminPurgedPersons); - items.addAll(adminPurgedCommunities); - items.addAll(adminPurgedPosts); - items.addAll(adminPurgedComments); - items.addAll(hiddenCommunities); - - modLogEventItems.addAll(items); - - if (items.isEmpty) hasReachedEnd = true; - currentPage++; - } while (!hasReachedEnd && modLogEventItems.length < limit); - - return {'modLogEventItems': modLogEventItems, 'hasReachedEnd': hasReachedEnd, 'currentPage': currentPage}; -} - -/// Given a modlog event, return a normalized [ModlogEventItem]. The response from the Lemmy API returns different types of events for different actions. -/// This function parses the event to a [ModlogEventItem] -ModlogEventItem parseModlogEvent(ModlogActionType type, dynamic event) { - final l10n = AppLocalizations.of(GlobalContext.context)!; - - switch (type) { - case ModlogActionType.modRemovePost: - return ModlogEventItem( - type: type, - dateTime: event['mod_remove_post']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - reason: event['mod_remove_post']['reason'], - post: ThunderPost.fromLemmyPost(event['post']), - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_remove_post']['removed'], - ); - case ModlogActionType.modLockPost: - return ModlogEventItem( - type: type, - dateTime: event['mod_lock_post']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - post: ThunderPost.fromLemmyPost(event['post']), - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_lock_post']['locked'], - ); - case ModlogActionType.modFeaturePost: - return ModlogEventItem( - type: type, - dateTime: event['mod_feature_post']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - post: ThunderPost.fromLemmyPost(event['post']), - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_feature_post']['featured'], - ); - case ModlogActionType.modRemoveComment: - return ModlogEventItem( - type: type, - dateTime: event['mod_remove_comment']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - reason: event['mod_remove_comment']['reason'], - user: event['commenter'] != null ? ThunderUser.fromLemmyUser(event['commenter']) : null, - post: ThunderPost.fromLemmyPost(event['post']), - comment: ThunderComment.fromLemmyComment(event['comment']), - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_remove_comment']['removed'], - ); - case ModlogActionType.modRemoveCommunity: - return ModlogEventItem( - type: type, - dateTime: event['mod_remove_community']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - reason: event['mod_remove_community']['reason'], - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_remove_community']['removed'], - ); - case ModlogActionType.modBanFromCommunity: - return ModlogEventItem( - type: type, - dateTime: event['mod_ban_from_community']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - reason: event['mod_ban_from_community']['reason'], - user: event['banned_person'] != null ? ThunderUser.fromLemmyUser(event['banned_person']) : null, - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_ban_from_community']['banned'], - ); - case ModlogActionType.modBan: - return ModlogEventItem( - type: type, - dateTime: event['mod_ban']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - reason: event['mod_ban']['reason'], - user: event['banned_person'] != null ? ThunderUser.fromLemmyUser(event['banned_person']) : null, - actioned: event['mod_ban']['banned'], - ); - case ModlogActionType.modAddCommunity: - return ModlogEventItem( - type: type, - dateTime: event['mod_add_community']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - user: event['modded_person'] != null ? ThunderUser.fromLemmyUser(event['modded_person']) : null, - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: !event['mod_add_community']['removed'], - ); - case ModlogActionType.modTransferCommunity: - return ModlogEventItem( - type: type, - dateTime: event['mod_transfer_community']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - user: event['modded_person'] != null ? ThunderUser.fromLemmyUser(event['modded_person']) : null, - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: true, - ); - case ModlogActionType.modAdd: - return ModlogEventItem( - type: type, - dateTime: event['mod_add']['when_'], - moderator: event['moderator'] != null ? ThunderUser.fromLemmyUser(event['moderator']) : null, - user: event['modded_person'] != null ? ThunderUser.fromLemmyUser(event['modded_person']) : null, - actioned: !event['mod_add']['removed'], - ); - case ModlogActionType.adminPurgePerson: - return ModlogEventItem( - type: type, - dateTime: event['admin_purge_person']['when_'], - admin: event['admin'] != null ? ThunderUser.fromLemmyUser(event['admin']) : null, - reason: event['admin_purge_person']['reason'], - actioned: true, - ); - case ModlogActionType.adminPurgeCommunity: - return ModlogEventItem( - type: type, - dateTime: event['admin_purge_community']['when_'], - admin: event['admin'] != null ? ThunderUser.fromLemmyUser(event['admin']) : null, - reason: event['admin_purge_community']['reason'], - actioned: true, - ); - case ModlogActionType.adminPurgePost: - return ModlogEventItem( - type: type, - dateTime: event['admin_purge_post']['when_'], - admin: event['admin'] != null ? ThunderUser.fromLemmyUser(event['admin']) : null, - reason: event['admin_purge_post']['reason'], - actioned: true, - ); - case ModlogActionType.adminPurgeComment: - return ModlogEventItem( - type: type, - dateTime: event['admin_purge_comment']['when_'], - admin: event['admin'] != null ? ThunderUser.fromLemmyUser(event['admin']) : null, - reason: event['admin_purge_comment']['reason'], - actioned: true, - ); - case ModlogActionType.modHideCommunity: - return ModlogEventItem( - type: type, - dateTime: event['mod_hide_community']['when'], - admin: event['admin'] != null ? ThunderUser.fromLemmyUser(event['admin']) : null, - reason: event['mod_hide_community']['reason'], - community: ThunderCommunity.fromLemmyCommunity(event['community']), - actioned: event['mod_hide_community']['hidden'], - ); - default: - throw Exception(l10n.missingErrorMessage); } } diff --git a/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart b/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart index 77cd7d515..e8d269ab4 100644 --- a/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart +++ b/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart @@ -1,7 +1,9 @@ import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/shared/utils/error_messages.dart'; part 'modlog_state.dart'; part 'modlog_cubit.freezed.dart'; @@ -116,7 +118,8 @@ class ModlogCubit extends Cubit { message: null, )); } catch (e) { - emit(state.copyWith(status: ModlogStatus.failure, message: e.toString())); + debugPrint(e.toString()); + emit(state.copyWith(status: ModlogStatus.failure, message: getExceptionErrorMessage(e))); } } } diff --git a/lib/src/features/modlog/presentation/pages/modlog_page.dart b/lib/src/features/modlog/presentation/pages/modlog_page.dart index b8b9f8bf8..29b1c6aab 100644 --- a/lib/src/features/modlog/presentation/pages/modlog_page.dart +++ b/lib/src/features/modlog/presentation/pages/modlog_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/shared/snackbar.dart'; @@ -47,18 +48,30 @@ class ModlogFeedPage extends StatefulWidget { class _ModlogFeedPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ModlogCubit( - repository: ModlogRepositoryImpl(), - )..fetchModlogFeed( - modlogActionType: widget.modlogActionType, - communityId: widget.communityId, - userId: widget.userId, - moderatorId: widget.moderatorId, - commentId: widget.commentId, - reset: true, - ), - child: ModlogFeedView(subtitle: widget.subtitle), + return FutureBuilder( + future: fetchActiveProfile(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final account = snapshot.data!; + return BlocProvider( + create: (_) => ModlogCubit( + repository: ModlogRepositoryImpl(account: account), + )..fetchModlogFeed( + modlogActionType: widget.modlogActionType, + communityId: widget.communityId, + userId: widget.userId, + moderatorId: widget.moderatorId, + commentId: widget.commentId, + reset: true, + ), + child: ModlogFeedView(subtitle: widget.subtitle), + ); + }, ); } } diff --git a/lib/src/features/notification/data/repositories/notification_repository.dart b/lib/src/features/notification/data/repositories/notification_repository.dart index 06d1ccf71..7a48ee69a 100644 --- a/lib/src/features/notification/data/repositories/notification_repository.dart +++ b/lib/src/features/notification/data/repositories/notification_repository.dart @@ -2,16 +2,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; /// Interface for a notification repository abstract class NotificationRepository { @@ -63,31 +60,18 @@ abstract class NotificationRepository { Future markAllNotificationsAsRead(); } -/// Implementation of [InstanceRepository] +/// Implementation of [NotificationRepository] class NotificationRepositoryImpl implements NotificationRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository - late LemmyApi lemmy; + final Account account; - /// The Piefed client to use for the repository - late PiefedApi piefed; + /// The API client to use for the repository + final ThunderApiClient _api; - NotificationRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + /// Creates a new NotificationRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + NotificationRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future> replies({ @@ -97,35 +81,7 @@ class NotificationRepositoryImpl implements NotificationRepository { int page = 1, }) async { if (account.anonymous) throw Exception(GlobalContext.l10n.userNotLoggedIn); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.getCommentReplies(page: page, limit: limit, sort: sort, unread: unread); - - final replies = response['replies'].map((crv) { - final comment = ThunderComment.fromLemmyCommentView(crv); - - return comment.copyWith( - recipient: ThunderUser.fromLemmyUser(crv['recipient']), - replyMentionId: crv['comment_reply']['id'], - read: crv['comment_reply']['read'], - ); - }).toList(); - return replies; - case ThreadiversePlatform.piefed: - final response = await piefed.getCommentReplies(page: page, limit: limit, sort: sort, unread: unread); - return response['replies'].map((crv) { - final comment = ThunderComment.fromPiefedCommentView(crv); - - return comment.copyWith( - recipient: ThunderUser.fromPiefedUser(crv['recipient']), - replyMentionId: crv['comment_reply']['id'], - read: crv['comment_reply']['read'], - ); - }).toList(); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.getCommentReplies(page: page, limit: limit, sort: sort, unread: unread); } @override @@ -133,14 +89,7 @@ class NotificationRepositoryImpl implements NotificationRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - await lemmy.markCommentReplyAsRead(replyId: replyId, read: read); - case ThreadiversePlatform.piefed: - await piefed.markCommentReplyAsRead(replyId: replyId, read: read); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.markCommentReplyAsRead(replyId: replyId, read: read); } @override @@ -150,36 +99,10 @@ class NotificationRepositoryImpl implements NotificationRepository { CommentSortType sort = CommentSortType.new_, int page = 1, }) async { - if (account.anonymous) throw Exception(GlobalContext.l10n.userNotLoggedIn); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.getCommentMentions(page: page, limit: limit, sort: sort, unread: unread); - - return response['mentions'].map((mention) { - final comment = ThunderComment.fromLemmyCommentView(mention); - - return comment.copyWith( - recipient: ThunderUser.fromLemmyUser(mention['recipient']), - replyMentionId: mention['person_mention']['id'], - read: mention['person_mention']['read'], - ); - }).toList(); - case ThreadiversePlatform.piefed: - final response = await piefed.getCommentMentions(page: page, limit: limit, sort: sort, unread: unread); - - return response['replies'].map((mention) { - final comment = ThunderComment.fromPiefedCommentView(mention); + final l10n = GlobalContext.l10n; + if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - return comment.copyWith( - recipient: ThunderUser.fromPiefedUser(mention['recipient']), - replyMentionId: mention['comment_reply']['id'], - read: mention['comment_reply']['read'], - ); - }).toList(); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.getCommentMentions(page: page, limit: limit, sort: sort, unread: unread); } @override @@ -187,14 +110,7 @@ class NotificationRepositoryImpl implements NotificationRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - await lemmy.markCommentMentionAsRead(mentionId: mentionId, read: read); - case ThreadiversePlatform.piefed: - await piefed.markCommentReplyAsRead(replyId: mentionId, read: read); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.markCommentMentionAsRead(mentionId: mentionId, read: read); } @override @@ -203,16 +119,10 @@ class NotificationRepositoryImpl implements NotificationRepository { int limit = 50, int page = 1, }) async { - if (account.anonymous) throw Exception(GlobalContext.l10n.userNotLoggedIn); + final l10n = GlobalContext.l10n; + if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getPrivateMessages(page: page, limit: limit, unread: unread); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.getPrivateMessages(page: page, limit: limit, unread: unread); } @override @@ -220,14 +130,7 @@ class NotificationRepositoryImpl implements NotificationRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - await lemmy.markPrivateMessageAsRead(messageId: messageId, read: read); - case ThreadiversePlatform.piefed: - await piefed.markPrivateMessageAsRead(messageId: messageId, read: read); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.markPrivateMessageAsRead(messageId: messageId, read: read); } @override @@ -235,14 +138,12 @@ class NotificationRepositoryImpl implements NotificationRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.unreadCount(); - case ThreadiversePlatform.piefed: - return await piefed.unreadCount(); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.unreadCount(); + return { + 'replies': response.replies, + 'mentions': response.mentions, + 'private_messages': response.privateMessages, + }; } @override @@ -250,13 +151,6 @@ class NotificationRepositoryImpl implements NotificationRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - await lemmy.markAllNotificationsAsRead(); - case ThreadiversePlatform.piefed: - await piefed.markAllNotificationsAsRead(); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.markAllNotificationsAsRead(); } } diff --git a/lib/src/features/post/data/repositories/post_repository.dart b/lib/src/features/post/data/repositories/post_repository.dart index c1f16dbdf..796a72d60 100644 --- a/lib/src/features/post/data/repositories/post_repository.dart +++ b/lib/src/features/post/data/repositories/post_repository.dart @@ -3,14 +3,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/enums/subscription_status.dart'; +import 'package:thunder/src/core/models/thunder_post_report.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -116,39 +115,24 @@ abstract class PostRepository { /// Implementation of [PostRepository] class PostRepositoryImpl implements PostRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository - late LemmyApi lemmy; - - /// The Piefed API to use for the repository - late PiefedApi piefed; - - PostRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + final Account account; + + /// The API client to use for the repository + final ThunderApiClient _api; + + /// Creates a new PostRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + PostRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future?> getPost(int postId, {int? commentId}) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getPost(postId, commentId: commentId); - case ThreadiversePlatform.piefed: - return await piefed.getPost(postId, commentId: commentId); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.getPost(postId, commentId: commentId); + return { + 'post': response.post, + 'moderators': response.moderators, + 'cross_posts': response.crossPosts, + }; } @override @@ -164,54 +148,21 @@ class PostRepositoryImpl implements PostRepository { bool? showSaved, bool? likedOnly, }) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - // Use page-based pagination for Lemmy for 0.19.x instances as there are some performance issues with cursor-based pagination. - // See https://github.com/LemmyNet/lemmy/issues/6171, https://lemmy.world/post/40266465/21176898 - // TODO: Once 1.x.x is released, we can switch back to cursor-based pagination. - final page = cursor != null ? int.tryParse(cursor) ?? 1 : 1; - - final response = await lemmy.getPosts( - page: page, - limit: limit, - postSortType: postSortType, - feedListType: feedListType, - communityId: communityId, - communityName: communityName, - showHidden: showHidden, - showSaved: showSaved, - ); - - // Return next page as string cursor for Lemmy - final nextPage = response['posts'].isNotEmpty ? (page + 1).toString() : null; - return { - 'posts': response['posts'], - 'next_page': nextPage, - }; - case ThreadiversePlatform.piefed: - // PieFed uses integer page-based pagination. The cursor in this case is the page number. - final page = cursor != null ? int.tryParse(cursor) ?? 1 : 1; - - final posts = await piefed.getPosts( - page: page, - limit: limit, - feedListType: feedListType, - postSortType: postSortType, - communityId: communityId, - communityName: communityName, - showSaved: showSaved, - likedOnly: likedOnly, - ); - - // Return next page as string cursor for PieFed - final nextPage = posts.isNotEmpty ? (page + 1).toString() : null; - return { - 'posts': posts, - 'next_page': nextPage, - }; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.getPosts( + cursor: cursor, + limit: limit, + feedListType: feedListType, + postSortType: postSortType, + communityId: communityId, + communityName: communityName, + showHidden: showHidden, + showSaved: showSaved, + ); + + return { + 'posts': response.posts, + 'next_page': response.nextPage, + }; } @override @@ -229,63 +180,34 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - ThunderPost? response; - - if (postIdBeingEdited != null) { - response = await lemmy.editPost( - postId: postIdBeingEdited, - title: name, - contents: body, - url: url?.isEmpty == true ? null : url, - customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, - altText: altText?.isEmpty == true ? null : altText, - nsfw: nsfw, - languageId: languageId, - ); - } else { - response = await lemmy.createPost( - communityId: communityId, - title: name, - contents: body, - url: url?.isEmpty == true ? null : url, - customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, - altText: altText?.isEmpty == true ? null : altText, - nsfw: nsfw, - languageId: languageId, - ); - } - - final posts = await parsePosts([response]); - return posts.firstOrNull!; - case ThreadiversePlatform.piefed: - ThunderPost? response; - - if (postIdBeingEdited != null) { - response = await piefed.editPost( - postId: postIdBeingEdited, - title: name, - contents: body, - nsfw: nsfw, - languageId: languageId, - ); - } else { - response = await piefed.createPost( - title: name, - communityId: communityId, - url: url, - contents: body, - nsfw: nsfw, - languageId: languageId, - ); - } - - final posts = await parsePosts([response]); - return posts.firstOrNull!; - default: - throw Exception('Unsupported platform: ${account.platform}'); + ThunderPost response; + + if (postIdBeingEdited != null) { + response = await _api.editPost( + postId: postIdBeingEdited, + title: name, + contents: body, + url: url?.isEmpty == true ? null : url, + customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, + altText: altText?.isEmpty == true ? null : altText, + nsfw: nsfw, + languageId: languageId, + ); + } else { + response = await _api.createPost( + communityId: communityId, + title: name, + contents: body, + url: url?.isEmpty == true ? null : url, + customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, + altText: altText?.isEmpty == true ? null : altText, + nsfw: nsfw, + languageId: languageId, + ); } + + final posts = await parsePosts([response]); + return posts.firstOrNull!; } @override @@ -293,16 +215,8 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.votePost(postId: post.id, score: score); - return response.copyWith(media: post.media); - case ThreadiversePlatform.piefed: - final response = await piefed.votePost(postId: post.id, score: score); - return response.copyWith(media: post.media); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.votePost(postId: post.id, score: score); + return response.copyWith(media: post.media); } @override @@ -310,16 +224,8 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.savePost(postId: post.id, save: save); - return response.copyWith(media: post.media); - case ThreadiversePlatform.piefed: - final response = await piefed.savePost(postId: post.id, save: save); - return response.copyWith(media: post.media); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.savePost(postId: post.id, save: save); + return response.copyWith(media: post.media); } @override @@ -327,14 +233,7 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.readPost(postIds: [postId], read: read); - case ThreadiversePlatform.piefed: - return await piefed.readPost(postIds: [postId], read: read); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.readPost(postIds: [postId], read: read); } @override @@ -342,22 +241,8 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - List failed = []; - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final response = await lemmy.readPost(postIds: postIds, read: read); - if (!response) failed = List.generate(postIds.length, (index) => index); - break; - case ThreadiversePlatform.piefed: - final success = await piefed.readPost(postIds: postIds, read: read); - if (!success) failed = List.generate(postIds.length, (index) => index); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - - return failed; + final success = await _api.readPost(postIds: postIds, read: read); + return success ? [] : List.generate(postIds.length, (index) => index); } @override @@ -365,14 +250,11 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.hidePost(postId: postId, hide: hide); - case ThreadiversePlatform.piefed: - throw Exception('Hiding posts is not supported on Piefed'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsHidePosts) { + throw UnsupportedFeatureException('Hiding posts', platformName: _api.platformName); } + + return await _api.hidePost(postId: postId, hide: hide); } @override @@ -380,14 +262,7 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.deletePost(postId: postId, deleted: delete); - case ThreadiversePlatform.piefed: - return await piefed.deletePost(postId: postId, deleted: delete); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.deletePost(postId: postId, deleted: delete); } @override @@ -395,14 +270,7 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.lockPost(postId: postId, locked: lock); - case ThreadiversePlatform.piefed: - return await piefed.lockPost(postId: postId, locked: lock); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.lockPost(postId: postId, locked: lock); } @override @@ -410,14 +278,7 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.pinPost(postId: postId, pinned: pin); - case ThreadiversePlatform.piefed: - return await piefed.pinPost(postId: postId, pinned: pin); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.pinPost(postId: postId, pinned: pin); } @override @@ -425,14 +286,7 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.removePost(postId: postId, removed: remove, reason: reason); - case ThreadiversePlatform.piefed: - return await piefed.removePost(postId: postId, removed: remove, reason: reason); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.removePost(postId: postId, removed: remove, reason: reason); } @override @@ -440,29 +294,31 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.reportPost(postId: postId, reason: reason); - case ThreadiversePlatform.piefed: - return await piefed.reportPost(postId: postId, reason: reason); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + await _api.reportPost(postId: postId, reason: reason); } @override - Future> getPostReports({int? postId, int page = 1, int limit = 20, bool unresolved = false, int? communityId}) async { + Future> getPostReports({ + int? postId, + int page = 1, + int limit = 20, + bool unresolved = false, + int? communityId, + }) async { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.getPostReports(postId: postId, page: page, limit: limit, unresolved: unresolved, communityId: communityId); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsPostReports) { + throw UnsupportedFeatureException('Post reports', platformName: _api.platformName); } + + return await _api.getPostReports( + postId: postId, + page: page, + limit: limit, + unresolved: unresolved, + communityId: communityId, + ); } @override @@ -470,14 +326,11 @@ class PostRepositoryImpl implements PostRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await lemmy.resolvePostReport(reportId: reportId, resolved: resolved); - case ThreadiversePlatform.piefed: - throw Exception('This feature is not yet available'); - default: - throw Exception('Unsupported platform: ${account.platform}'); + if (!_api.supportsPostReports) { + throw UnsupportedFeatureException('Post reports', platformName: _api.platformName); } + + return await _api.resolvePostReport(reportId: reportId, resolved: resolved); } @override diff --git a/lib/src/features/post/presentation/cubit/create_post_cubit.dart b/lib/src/features/post/presentation/cubit/create_post_cubit.dart index eda082bc7..543aaa6fa 100644 --- a/lib/src/features/post/presentation/cubit/create_post_cubit.dart +++ b/lib/src/features/post/presentation/cubit/create_post_cubit.dart @@ -3,11 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/shared/utils/error_messages.dart'; import 'package:thunder/src/app/utils/global_context.dart'; @@ -47,23 +43,11 @@ class CreatePostCubit extends Cubit { isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadInProgress)) : emit(state.copyWith(status: CreatePostStatus.imageUploadInProgress)); try { + final accountRepository = AccountRepositoryImpl(account: account); + for (String imageFile in imageFiles) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final result = await LemmyApi(account: account, debug: kDebugMode, version: version).uploadImage(imageFile); - String url = "https://${account.instance}/pictrs/image/${result['files'][0]['file']}"; - - urls.add(url); - break; - case ThreadiversePlatform.piefed: - final url = await PiefedApi(account: account, debug: kDebugMode, version: version).uploadImage(imageFile); - urls.add(url); - break; - default: - throw Exception(l10n.unexpectedError); - } + final url = await accountRepository.uploadImage(imageFile); + urls.add(url); // Add a delay between each upload to avoid possible rate limiting await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); diff --git a/lib/src/features/search/data/repositories/search_repository.dart b/lib/src/features/search/data/repositories/search_repository.dart index 928b55d45..5efde3ceb 100644 --- a/lib/src/features/search/data/repositories/search_repository.dart +++ b/lib/src/features/search/data/repositories/search_repository.dart @@ -2,16 +2,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/utils/links.dart'; @@ -19,7 +17,6 @@ import 'package:thunder/src/shared/utils/links.dart'; /// Interface for a search repository abstract class SearchRepository { /// Searches for posts, comments, users, communities, etc. - /// @TODO: Change the return type to an internal model Future> search({ required String query, MetaSearchType? type, @@ -38,28 +35,15 @@ abstract class SearchRepository { /// Implementation of [SearchRepository] class SearchRepositoryImpl implements SearchRepository { /// The account to use for methods invoked in this repository - Account account; - - /// The Lemmy client to use for the repository (initialized for Lemmy platforms) - LemmyApi? _lemmy; + final Account account; - /// The Piefed client to use for the repository (initialized for Piefed platforms) - PiefedApi? _piefed; + /// The API client to use for the repository + final ThunderApiClient _api; - SearchRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - _lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - _piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + /// Creates a new SearchRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + SearchRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future> search({ @@ -72,97 +56,54 @@ class SearchRepositoryImpl implements SearchRepository { int? communityId, int? creatorId, }) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - final lemmy = _lemmy!; - final response = await lemmy.search( - query: query, - type: type, - sort: sort, - listingType: listingType, - limit: limit, - page: page, - communityId: communityId, - creatorId: creatorId, - ); - - List communities = response['communities'] != null ? response['communities'].map((cv) => ThunderCommunity.fromLemmyCommunityView(cv)).toList() : []; - List users = response['users'] != null ? response['users'].map((pv) => ThunderUser.fromLemmyUserView(pv)).toList() : []; - List posts = response['posts'] != null ? response['posts'].map((pv) => ThunderPost.fromLemmyPostView(pv)).toList() : []; - List comments = response['comments'] != null ? response['comments'].map((cv) => ThunderComment.fromLemmyCommentView(cv)).toList() : []; - - if (isValidUrl(query)) { - final resolve = await lemmy.resolve(query: query); - - if (resolve['community'] != null) { - communities.add(resolve['community']); - } else if (resolve['user'] != null) { - users.add(resolve['user']); - } else if (resolve['post'] != null) { - posts.add(resolve['post']); - } else if (resolve['comment'] != null) { - comments.add(resolve['comment']); - } - } - - return { - 'type': MetaSearchType.values.firstWhere((e) => e.searchType == response['type_']), - 'comments': comments, - 'posts': posts, - 'communities': communities, - 'users': users, - }; - case ThreadiversePlatform.piefed: - final piefed = _piefed!; - final response = await piefed.search( - query: query, - type: type, - sort: sort, - listingType: listingType, - limit: limit, - page: page, - ); - - List communities = response['communities'] != null ? response['communities'].map((cv) => ThunderCommunity.fromPiefedCommunityView(cv)).toList() : []; - List users = response['users'] != null ? response['users'].map((pv) => ThunderUser.fromPiefedUserView(pv)).toList() : []; - List posts = response['posts'] != null ? response['posts'].map((pv) => ThunderPost.fromPiefedPostView(pv)).toList() : []; - List comments = response['comments'] != null ? response['comments'].map((cv) => ThunderComment.fromPiefedCommentView(cv)).toList() : []; - - if (isValidUrl(query)) { - final resolve = await piefed.resolve(query: query); - - if (resolve['community'] != null) { - communities.add(resolve['community']); - } else if (resolve['user'] != null) { - users.add(resolve['user']); - } else if (resolve['post'] != null) { - posts.add(resolve['post']); - } else if (resolve['comment'] != null) { - comments.add(resolve['comment']); - } - } - - return { - 'type': MetaSearchType.values.firstWhere((e) => e.searchType == response['type_']), - 'posts': posts, - 'comments': comments, - 'communities': communities, - 'users': users, - }; - default: - throw Exception('Unsupported platform: ${account.platform}'); + final response = await _api.search( + query: query, + type: type, + sort: sort, + listingType: listingType, + limit: limit, + page: page, + communityId: communityId, + creatorId: creatorId, + ); + + // Lists are already parsed by the API client + List communities = response.communities; + List users = response.users; + List posts = response.posts; + List comments = response.comments; + + // Try to resolve if the query is a URL + if (isValidUrl(query)) { + final resolveResponse = await _api.resolve(query: query); + if (resolveResponse.community != null) { + communities.add(resolveResponse.community!); + } else if (resolveResponse.user != null) { + users.add(resolveResponse.user!); + } else if (resolveResponse.post != null) { + posts.add(resolveResponse.post!); + } else if (resolveResponse.comment != null) { + comments.add(resolveResponse.comment!); + } } + + return { + 'type': response.type, + 'comments': comments, + 'posts': posts, + 'communities': communities, + 'users': users, + }; } @override Future> resolve({required String query}) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await _lemmy!.resolve(query: query); - case ThreadiversePlatform.piefed: - return await _piefed!.resolve(query: query); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.resolve(query: query); + return { + 'community': response.community, + 'post': response.post, + 'comment': response.comment, + 'person': response.user, + }; } } diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/bloc/search_bloc.dart index 3a33d18c9..60e77f24d 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/bloc/search_bloc.dart @@ -18,6 +18,7 @@ import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/shared/utils/error_messages.dart'; part 'search_event.dart'; part 'search_state.dart'; @@ -202,7 +203,7 @@ class SearchBloc extends Bloc { viewingAll: event.query.isEmpty, )); } catch (e) { - return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); + return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); } } @@ -269,14 +270,14 @@ class SearchBloc extends Bloc { attemptCount++; debugPrint('SearchBloc: Continue search attempt $attemptCount failed: $e'); if (attemptCount >= 2) { - return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); + return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); } await Future.delayed(const Duration(milliseconds: 500)); } } } catch (e) { debugPrint('SearchBloc: Continue search failed: $e'); - return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); + return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); } } diff --git a/lib/src/features/user/data/repositories/user_repository.dart b/lib/src/features/user/data/repositories/user_repository.dart index 23b8a016e..e5a2ad60d 100644 --- a/lib/src/features/user/data/repositories/user_repository.dart +++ b/lib/src/features/user/data/repositories/user_repository.dart @@ -2,14 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/network/piefed_api.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/core/network/api_client_factory.dart'; +import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; /// Interface for a user repository abstract class UserRepository { @@ -27,31 +25,18 @@ abstract class UserRepository { Future block(int personId, bool block); } -/// Implementation of [UserRepository] using Lemmy API +/// Implementation of [UserRepository] using the unified API client class UserRepositoryImpl implements UserRepository { /// The account to use for methods invoked in this repository - Account account; + final Account account; - /// The Lemmy client to use for the repository - late LemmyApi client; + /// The API client to use for the repository + final ThunderApiClient _api; - /// The Piefed client to use for the repository - late PiefedApi piefed; - - UserRepositoryImpl({required this.account}) { - final version = PlatformVersionCache().get(account.instance); - - switch (account.platform) { - case ThreadiversePlatform.lemmy: - client = LemmyApi(account: account, debug: kDebugMode, version: version); - break; - case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); - break; - default: - throw Exception('Unsupported platform: ${account.platform}'); - } - } + /// Creates a new UserRepositoryImpl. + /// + /// An optional [api] client can be provided for testing. + UserRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override Future?> getUser({ @@ -62,14 +47,22 @@ class UserRepositoryImpl implements UserRepository { int? limit, bool? saved, }) async { - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.getUser(userId: userId, username: username, sort: sort, page: page, limit: limit, saved: saved); - case ThreadiversePlatform.piefed: - return await piefed.getUser(userId: userId, username: username, sort: sort, page: page, limit: limit, saved: saved); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + final response = await _api.getUser( + userId: userId, + username: username, + sort: sort, + page: page, + limit: limit, + saved: saved, + ); + + return { + 'user': response.user, + 'site': response.site, + 'posts': response.posts, + 'comments': response.comments, + 'moderates': response.moderates, + }; } @override @@ -77,13 +70,6 @@ class UserRepositoryImpl implements UserRepository { final l10n = GlobalContext.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - switch (account.platform) { - case ThreadiversePlatform.lemmy: - return await client.blockUser(userId: personId, block: block); - case ThreadiversePlatform.piefed: - return await piefed.blockUser(userId: personId, block: block); - default: - throw Exception('Unsupported platform: ${account.platform}'); - } + return await _api.blockUser(userId: personId, block: block); } } diff --git a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart b/lib/src/features/user/presentation/bloc/user_settings_bloc.dart index 56d03dfe3..300c1fddc 100644 --- a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart +++ b/lib/src/features/user/presentation/bloc/user_settings_bloc.dart @@ -3,8 +3,6 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:stream_transform/stream_transform.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; @@ -304,8 +302,7 @@ class UserSettingsBloc extends Bloc { final account = await fetchActiveProfile(); if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - final version = PlatformVersionCache().get(account.instance); - await LemmyApi(account: account, version: version).deleteImage(file: event.id, token: event.deleteToken); + await accountRepository.deleteImage(file: event.id, token: event.deleteToken); return emit(state.copyWith(status: UserSettingsStatus.succeededListingMedia, images: state.images)); } catch (e) { diff --git a/lib/src/shared/utils/error_messages.dart b/lib/src/shared/utils/error_messages.dart index 5379ee8e6..6607197a7 100644 --- a/lib/src/shared/utils/error_messages.dart +++ b/lib/src/shared/utils/error_messages.dart @@ -3,11 +3,11 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/network/lemmy_api.dart'; +import 'package:thunder/src/core/network/api_exception.dart'; /// Generates a user-friendly error message from an exception (or any thrown object) String getExceptionErrorMessage(Object? e, {String? additionalInfo}) { - if (e is LemmyApiException) { + if (e is ApiException) { return getErrorMessage(GlobalContext.context, e.message, additionalInfo: e.errorCode) ?? e.message; }