Main coves client
1import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
2import 'package:dio/dio.dart';
3import 'package:flutter/foundation.dart';
4
5import 'api_exceptions.dart';
6
7/// Vote Service
8///
9/// Handles vote/like interactions by writing directly to the user's PDS.
10/// This follows the atProto architecture where clients write to PDSs and
11/// AppViews only index public data.
12///
13/// **Correct Architecture**:
14/// Mobile Client → User's PDS (com.atproto.repo.createRecord)
15/// ↓
16/// Jetstream
17/// ↓
18/// Backend AppView (indexes vote events)
19///
20/// Uses these XRPC endpoints:
21/// - com.atproto.repo.createRecord (create vote)
22/// - com.atproto.repo.deleteRecord (delete vote)
23/// - com.atproto.repo.listRecords (find existing votes)
24///
25/// **DPoP Authentication TODO**:
26/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication.
27/// The current implementation uses a placeholder that will not work with real PDSs.
28/// This needs to be implemented using OAuthSession's DPoP capabilities once
29/// available in the atproto_oauth_flutter package.
30///
31/// Required for production:
32/// - Authorization: DPoP <access_token>
33/// - DPoP: <proof> (signed JWT proving key possession)
34class VoteService {
35 VoteService({
36 Future<OAuthSession?> Function()? sessionGetter,
37 String? Function()? didGetter,
38 String? Function()? pdsUrlGetter,
39 }) : _sessionGetter = sessionGetter,
40 _didGetter = didGetter,
41 _pdsUrlGetter = pdsUrlGetter {
42 _dio = Dio(
43 BaseOptions(
44 connectTimeout: const Duration(seconds: 30),
45 receiveTimeout: const Duration(seconds: 30),
46 headers: {'Content-Type': 'application/json'},
47 ),
48 );
49
50 // TODO: Add DPoP auth interceptor
51 // atProto PDSs require DPoP authentication, not Bearer tokens
52 // This needs implementation using OAuthSession's DPoP support
53 _dio.interceptors.add(
54 InterceptorsWrapper(
55 onRequest: (options, handler) async {
56 // PLACEHOLDER: This does not implement DPoP authentication
57 // and will fail on real PDSs with "Malformed token" errors
58 if (_sessionGetter != null) {
59 final session = await _sessionGetter();
60 if (session != null) {
61 // TODO: Generate DPoP proof and set headers:
62 // options.headers['Authorization'] = 'DPoP ${session.accessToken}';
63 // options.headers['DPoP'] = dpopProof;
64 if (kDebugMode) {
65 debugPrint('⚠️ DPoP authentication not yet implemented');
66 }
67 }
68 }
69 handler.next(options);
70 },
71 onError: (error, handler) {
72 if (kDebugMode) {
73 debugPrint('❌ PDS API Error: ${error.message}');
74 debugPrint(' Status: ${error.response?.statusCode}');
75 debugPrint(' Data: ${error.response?.data}');
76 }
77 handler.next(error);
78 },
79 ),
80 );
81 }
82
83 late final Dio _dio;
84 final Future<OAuthSession?> Function()? _sessionGetter;
85 final String? Function()? _didGetter;
86 final String? Function()? _pdsUrlGetter;
87
88 /// Collection name for vote records
89 static const String voteCollection = 'social.coves.interaction.vote';
90
91 /// Create or toggle vote
92 ///
93 /// Implements smart toggle logic:
94 /// 1. Query PDS for existing vote on this post
95 /// 2. If exists with same direction → Delete (toggle off)
96 /// 3. If exists with different direction → Delete old + Create new
97 /// 4. If no existing vote → Create new
98 ///
99 /// Parameters:
100 /// - [postUri]: AT-URI of the post (e.g.,
101 /// "at://did:plc:xyz/social.coves.post.record/abc123")
102 /// - [postCid]: Content ID of the post (for strong reference)
103 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
104 ///
105 /// Returns:
106 /// - VoteResponse with uri/cid/rkey if created
107 /// - VoteResponse with deleted=true if toggled off
108 ///
109 /// Throws:
110 /// - ApiException for API errors
111 Future<VoteResponse> createVote({
112 required String postUri,
113 required String postCid,
114 String direction = 'up',
115 }) async {
116 try {
117 // Get user's DID and PDS URL
118 final userDid = _didGetter?.call();
119 final pdsUrl = _pdsUrlGetter?.call();
120
121 if (userDid == null || userDid.isEmpty) {
122 throw ApiException('User not authenticated - no DID available');
123 }
124
125 if (pdsUrl == null || pdsUrl.isEmpty) {
126 throw ApiException('PDS URL not available');
127 }
128
129 if (kDebugMode) {
130 debugPrint('🗳️ Creating vote on PDS');
131 debugPrint(' Post: $postUri');
132 debugPrint(' Direction: $direction');
133 debugPrint(' PDS: $pdsUrl');
134 }
135
136 // Step 1: Check for existing vote
137 final existingVote = await _findExistingVote(
138 userDid: userDid,
139 pdsUrl: pdsUrl,
140 postUri: postUri,
141 );
142
143 if (existingVote != null) {
144 if (kDebugMode) {
145 debugPrint(' Found existing vote: ${existingVote.direction}');
146 }
147
148 // If same direction, toggle off (delete)
149 if (existingVote.direction == direction) {
150 if (kDebugMode) {
151 debugPrint(' Same direction - deleting vote');
152 }
153 await _deleteVote(
154 userDid: userDid,
155 pdsUrl: pdsUrl,
156 rkey: existingVote.rkey,
157 );
158 return const VoteResponse(deleted: true);
159 }
160
161 // Different direction - delete old vote first
162 if (kDebugMode) {
163 debugPrint(' Different direction - switching vote');
164 }
165 await _deleteVote(
166 userDid: userDid,
167 pdsUrl: pdsUrl,
168 rkey: existingVote.rkey,
169 );
170 }
171
172 // Step 2: Create new vote
173 final response = await _createVote(
174 userDid: userDid,
175 pdsUrl: pdsUrl,
176 postUri: postUri,
177 postCid: postCid,
178 direction: direction,
179 );
180
181 if (kDebugMode) {
182 debugPrint('✅ Vote created: ${response.uri}');
183 }
184
185 return response;
186 } on DioException catch (e) {
187 throw ApiException.fromDioError(e);
188 } catch (e) {
189 throw ApiException('Failed to create vote: $e');
190 }
191 }
192
193 /// Find existing vote for a post
194 ///
195 /// Queries the user's PDS to check if they've already voted on this post.
196 ///
197 /// Returns ExistingVote with direction and rkey if found, null otherwise.
198 Future<ExistingVote?> _findExistingVote({
199 required String userDid,
200 required String pdsUrl,
201 required String postUri,
202 }) async {
203 try {
204 // Query listRecords to find votes
205 final response = await _dio.get<Map<String, dynamic>>(
206 '$pdsUrl/xrpc/com.atproto.repo.listRecords',
207 queryParameters: {
208 'repo': userDid,
209 'collection': voteCollection,
210 'limit': 100,
211 'reverse': true, // Most recent first
212 },
213 );
214
215 if (response.data == null) {
216 return null;
217 }
218
219 final records = response.data!['records'] as List<dynamic>?;
220 if (records == null || records.isEmpty) {
221 return null;
222 }
223
224 // Find vote for this specific post
225 for (final record in records) {
226 final recordMap = record as Map<String, dynamic>;
227 final value = recordMap['value'] as Map<String, dynamic>?;
228
229 if (value == null) {
230 continue;
231 }
232
233 final subject = value['subject'] as Map<String, dynamic>?;
234 if (subject == null) {
235 continue;
236 }
237
238 final subjectUri = subject['uri'] as String?;
239 if (subjectUri == postUri) {
240 // Found existing vote!
241 final direction = value['direction'] as String;
242 final uri = recordMap['uri'] as String;
243
244 // Extract rkey from URI
245 // Format: at://did:plc:xyz/social.coves.interaction.vote/3kby...
246 final rkey = uri.split('/').last;
247
248 return ExistingVote(direction: direction, rkey: rkey);
249 }
250 }
251
252 return null;
253 } on DioException catch (e) {
254 if (kDebugMode) {
255 debugPrint('⚠️ Failed to list votes: ${e.message}');
256 }
257 // Return null on error - assume no existing vote
258 return null;
259 }
260 }
261
262 /// Create vote record on PDS
263 ///
264 /// Calls com.atproto.repo.createRecord with the vote record.
265 Future<VoteResponse> _createVote({
266 required String userDid,
267 required String pdsUrl,
268 required String postUri,
269 required String postCid,
270 required String direction,
271 }) async {
272 // Build the vote record according to the lexicon
273 final record = {
274 r'$type': voteCollection,
275 'subject': {
276 'uri': postUri,
277 'cid': postCid,
278 },
279 'direction': direction,
280 'createdAt': DateTime.now().toUtc().toIso8601String(),
281 };
282
283 final response = await _dio.post<Map<String, dynamic>>(
284 '$pdsUrl/xrpc/com.atproto.repo.createRecord',
285 data: {
286 'repo': userDid,
287 'collection': voteCollection,
288 'record': record,
289 },
290 );
291
292 if (response.data == null) {
293 throw ApiException('Empty response from PDS');
294 }
295
296 final uri = response.data!['uri'] as String?;
297 final cid = response.data!['cid'] as String?;
298
299 if (uri == null || cid == null) {
300 throw ApiException('Invalid response from PDS - missing uri or cid');
301 }
302
303 // Extract rkey from URI
304 final rkey = uri.split('/').last;
305
306 return VoteResponse(
307 uri: uri,
308 cid: cid,
309 rkey: rkey,
310 deleted: false,
311 );
312 }
313
314 /// Delete vote record from PDS
315 ///
316 /// Calls com.atproto.repo.deleteRecord to remove the vote.
317 Future<void> _deleteVote({
318 required String userDid,
319 required String pdsUrl,
320 required String rkey,
321 }) async {
322 await _dio.post<Map<String, dynamic>>(
323 '$pdsUrl/xrpc/com.atproto.repo.deleteRecord',
324 data: {
325 'repo': userDid,
326 'collection': voteCollection,
327 'rkey': rkey,
328 },
329 );
330 }
331}
332
333/// Vote Response
334///
335/// Response from createVote operation.
336class VoteResponse {
337 const VoteResponse({
338 this.uri,
339 this.cid,
340 this.rkey,
341 required this.deleted,
342 });
343
344 /// AT-URI of the created vote record
345 final String? uri;
346
347 /// Content ID of the vote record
348 final String? cid;
349
350 /// Record key (rkey) of the vote - last segment of URI
351 final String? rkey;
352
353 /// Whether the vote was deleted (toggled off)
354 final bool deleted;
355}
356
357/// Existing Vote
358///
359/// Represents a vote that already exists on the PDS.
360class ExistingVote {
361 const ExistingVote({required this.direction, required this.rkey});
362
363 /// Vote direction ("up" or "down")
364 final String direction;
365
366 /// Record key for deletion
367 final String rkey;
368}