Main coves client
1import 'package:dio/dio.dart';
2import 'package:flutter/foundation.dart';
3
4import '../config/environment_config.dart';
5import '../models/coves_session.dart';
6import 'api_exceptions.dart';
7import 'auth_interceptor.dart';
8
9/// Comment Service
10///
11/// Handles comment creation through the Coves backend.
12///
13/// **Architecture with Backend OAuth**:
14/// With sealed tokens, the client cannot write directly to the user's PDS
15/// (no DPoP keys available). Instead, comments go through the Coves backend:
16///
17/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
18///
19/// The backend:
20/// 1. Unseals the token to get the actual access/refresh tokens
21/// 2. Uses stored DPoP keys to sign requests
22/// 3. Writes to the user's PDS on their behalf
23///
24/// **Backend Endpoint**:
25/// - POST /xrpc/social.coves.community.comment.create
26class CommentService {
27 CommentService({
28 Future<CovesSession?> Function()? sessionGetter,
29 Future<bool> Function()? tokenRefresher,
30 Future<void> Function()? signOutHandler,
31 Dio? dio,
32 }) : _sessionGetter = sessionGetter {
33 _dio =
34 dio ??
35 Dio(
36 BaseOptions(
37 baseUrl: EnvironmentConfig.current.apiUrl,
38 connectTimeout: const Duration(seconds: 30),
39 receiveTimeout: const Duration(seconds: 30),
40 headers: {'Content-Type': 'application/json'},
41 ),
42 );
43
44 // Add shared 401 retry interceptor
45 _dio.interceptors.add(
46 createAuthInterceptor(
47 sessionGetter: sessionGetter,
48 tokenRefresher: tokenRefresher,
49 signOutHandler: signOutHandler,
50 serviceName: 'CommentService',
51 dio: _dio,
52 ),
53 );
54 }
55
56 final Future<CovesSession?> Function()? _sessionGetter;
57 late final Dio _dio;
58
59 /// Create a comment
60 ///
61 /// Sends comment request to the Coves backend, which writes to the
62 /// user's PDS.
63 ///
64 /// Parameters:
65 /// - [rootUri]: AT-URI of the root post (always the original post)
66 /// - [rootCid]: CID of the root post
67 /// - [parentUri]: AT-URI of the parent (post or comment)
68 /// - [parentCid]: CID of the parent
69 /// - [content]: Comment text content
70 ///
71 /// Returns:
72 /// - CreateCommentResponse with uri and cid of the created comment
73 ///
74 /// Throws:
75 /// - ApiException for API errors
76 /// - AuthenticationException for auth failures
77 Future<CreateCommentResponse> createComment({
78 required String rootUri,
79 required String rootCid,
80 required String parentUri,
81 required String parentCid,
82 required String content,
83 }) async {
84 try {
85 final session = await _sessionGetter?.call();
86
87 if (session == null) {
88 throw AuthenticationException(
89 'User not authenticated - no session available',
90 );
91 }
92
93 if (kDebugMode) {
94 debugPrint('💬 Creating comment via backend');
95 debugPrint(' Root: $rootUri');
96 debugPrint(' Parent: $parentUri');
97 debugPrint(' Content length: ${content.length}');
98 }
99
100 // Send comment request to backend
101 // Note: Authorization header is added by the interceptor
102 final response = await _dio.post<Map<String, dynamic>>(
103 '/xrpc/social.coves.community.comment.create',
104 data: {
105 'reply': {
106 'root': {'uri': rootUri, 'cid': rootCid},
107 'parent': {'uri': parentUri, 'cid': parentCid},
108 },
109 'content': content,
110 },
111 );
112
113 final data = response.data;
114 if (data == null) {
115 throw ApiException('Invalid response from server - no data');
116 }
117
118 final uri = data['uri'] as String?;
119 final cid = data['cid'] as String?;
120
121 if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
122 throw ApiException(
123 'Invalid response from server - missing uri or cid',
124 );
125 }
126
127 if (kDebugMode) {
128 debugPrint('✅ Comment created: $uri');
129 }
130
131 return CreateCommentResponse(uri: uri, cid: cid);
132 } on DioException catch (e) {
133 if (kDebugMode) {
134 debugPrint('❌ Comment creation failed: ${e.message}');
135 debugPrint(' Status: ${e.response?.statusCode}');
136 debugPrint(' Data: ${e.response?.data}');
137 }
138
139 if (e.response?.statusCode == 401) {
140 throw AuthenticationException(
141 'Authentication failed. Please sign in again.',
142 originalError: e,
143 );
144 }
145
146 throw ApiException(
147 'Failed to create comment: ${e.message}',
148 statusCode: e.response?.statusCode,
149 originalError: e,
150 );
151 } on AuthenticationException {
152 rethrow;
153 } on ApiException {
154 rethrow;
155 } on Exception catch (e) {
156 throw ApiException('Failed to create comment: $e');
157 }
158 }
159}
160
161/// Response from comment creation
162class CreateCommentResponse {
163 const CreateCommentResponse({required this.uri, required this.cid});
164
165 /// AT-URI of the created comment record
166 final String uri;
167
168 /// CID of the created comment record
169 final String cid;
170}