Main coves client
1import 'dart:convert';
2
3import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
4import 'package:flutter/foundation.dart';
5
6import 'api_exceptions.dart';
7
8/// Vote Service
9///
10/// Handles vote/like interactions by writing directly to the user's PDS.
11/// This follows the atProto architecture where clients write to PDSs and
12/// AppViews only index public data.
13///
14/// **Correct Architecture**:
15/// Mobile Client → User's PDS (com.atproto.repo.createRecord)
16/// ↓
17/// Jetstream
18/// ↓
19/// Backend AppView (indexes vote events)
20///
21/// Uses these XRPC endpoints:
22/// - com.atproto.repo.createRecord (create vote)
23/// - com.atproto.repo.deleteRecord (delete vote)
24/// - com.atproto.repo.listRecords (find existing votes)
25///
26/// **DPoP Authentication**:
27/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication.
28/// Uses OAuthSession.fetchHandler which automatically handles:
29/// - Authorization: DPoP <access_token>
30/// - DPoP: <proof> (signed JWT proving key possession)
31/// - Automatic token refresh on expiry
32/// - Nonce management for replay protection
33class VoteService {
34 VoteService({
35 Future<OAuthSession?> Function()? sessionGetter,
36 String? Function()? didGetter,
37 String? Function()? pdsUrlGetter,
38 }) : _sessionGetter = sessionGetter,
39 _didGetter = didGetter,
40 _pdsUrlGetter = pdsUrlGetter;
41
42 final Future<OAuthSession?> Function()? _sessionGetter;
43 final String? Function()? _didGetter;
44 final String? Function()? _pdsUrlGetter;
45
46 /// Collection name for vote records
47 static const String voteCollection = 'social.coves.feed.vote';
48
49 /// Get all votes for the current user
50 ///
51 /// Queries the user's PDS for all their vote records and returns a map
52 /// of post URI -> vote info. This is used to initialize vote state when
53 /// loading the feed.
54 ///
55 /// Returns:
56 /// - Map<String, VoteInfo> where key is the post URI
57 /// - Empty map if not authenticated or no votes found
58 Future<Map<String, VoteInfo>> getUserVotes() async {
59 try {
60 final userDid = _didGetter?.call();
61 if (userDid == null || userDid.isEmpty) {
62 return {};
63 }
64
65 final session = await _sessionGetter?.call();
66 if (session == null) {
67 return {};
68 }
69
70 final votes = <String, VoteInfo>{};
71 String? cursor;
72
73 // Paginate through all vote records
74 do {
75 final url =
76 cursor == null
77 ? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100'
78 : '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&cursor=$cursor';
79
80 final response = await session.fetchHandler(url, method: 'GET');
81
82 if (response.statusCode != 200) {
83 if (kDebugMode) {
84 debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
85 }
86 break;
87 }
88
89 final data = jsonDecode(response.body) as Map<String, dynamic>;
90 final records = data['records'] as List<dynamic>?;
91
92 if (records != null) {
93 for (final record in records) {
94 final recordMap = record as Map<String, dynamic>;
95 final value = recordMap['value'] as Map<String, dynamic>?;
96 final uri = recordMap['uri'] as String?;
97
98 if (value == null || uri == null) {
99 continue;
100 }
101
102 final subject = value['subject'] as Map<String, dynamic>?;
103 final direction = value['direction'] as String?;
104
105 if (subject == null || direction == null) {
106 continue;
107 }
108
109 final subjectUri = subject['uri'] as String?;
110 if (subjectUri != null) {
111 // Extract rkey from vote URI
112 final rkey = uri.split('/').last;
113
114 votes[subjectUri] = VoteInfo(
115 direction: direction,
116 voteUri: uri,
117 rkey: rkey,
118 );
119 }
120 }
121 }
122
123 cursor = data['cursor'] as String?;
124 } while (cursor != null);
125
126 if (kDebugMode) {
127 debugPrint('📊 Loaded ${votes.length} votes from PDS');
128 }
129
130 return votes;
131 } on Exception catch (e) {
132 if (kDebugMode) {
133 debugPrint('⚠️ Failed to load user votes: $e');
134 }
135 return {};
136 }
137 }
138
139 /// Create or toggle vote
140 ///
141 /// Implements smart toggle logic:
142 /// 1. Query PDS for existing vote on this post (or use cached state)
143 /// 2. If exists with same direction → Delete (toggle off)
144 /// 3. If exists with different direction → Delete old + Create new
145 /// 4. If no existing vote → Create new
146 ///
147 /// Parameters:
148 /// - [postUri]: AT-URI of the post (e.g.,
149 /// "at://did:plc:xyz/social.coves.post.record/abc123")
150 /// - [postCid]: Content ID of the post (for strong reference)
151 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
152 /// - [existingVoteRkey]: Optional rkey from cached state (avoids O(n) lookup)
153 /// - [existingVoteDirection]: Optional direction from cached state
154 ///
155 /// Returns:
156 /// - VoteResponse with uri/cid/rkey if created
157 /// - VoteResponse with deleted=true if toggled off
158 ///
159 /// Throws:
160 /// - ApiException for API errors
161 Future<VoteResponse> createVote({
162 required String postUri,
163 required String postCid,
164 String direction = 'up',
165 String? existingVoteRkey,
166 String? existingVoteDirection,
167 }) async {
168 try {
169 // Get user's DID and PDS URL
170 final userDid = _didGetter?.call();
171 final pdsUrl = _pdsUrlGetter?.call();
172
173 if (userDid == null || userDid.isEmpty) {
174 throw ApiException('User not authenticated - no DID available');
175 }
176
177 if (pdsUrl == null || pdsUrl.isEmpty) {
178 throw ApiException('PDS URL not available');
179 }
180
181 if (kDebugMode) {
182 debugPrint('🗳️ Creating vote on PDS');
183 debugPrint(' Post: $postUri');
184 debugPrint(' Direction: $direction');
185 debugPrint(' PDS: $pdsUrl');
186 }
187
188 // Step 1: Check for existing vote
189 // Use cached state if available to avoid O(n) PDS lookup
190 ExistingVote? existingVote;
191 if (existingVoteRkey != null && existingVoteDirection != null) {
192 existingVote = ExistingVote(
193 direction: existingVoteDirection,
194 rkey: existingVoteRkey,
195 );
196 if (kDebugMode) {
197 debugPrint(' Using cached vote state (avoiding PDS lookup)');
198 }
199 } else {
200 existingVote = await _findExistingVote(
201 userDid: userDid,
202 postUri: postUri,
203 );
204 }
205
206 if (existingVote != null) {
207 if (kDebugMode) {
208 debugPrint(' Found existing vote: ${existingVote.direction}');
209 }
210
211 // If same direction, toggle off (delete)
212 if (existingVote.direction == direction) {
213 if (kDebugMode) {
214 debugPrint(' Same direction - deleting vote');
215 }
216 await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
217 return const VoteResponse(deleted: true);
218 }
219
220 // Different direction - delete old vote first
221 if (kDebugMode) {
222 debugPrint(' Different direction - switching vote');
223 }
224 await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
225 }
226
227 // Step 2: Create new vote
228 final response = await _createVote(
229 userDid: userDid,
230 postUri: postUri,
231 postCid: postCid,
232 direction: direction,
233 );
234
235 if (kDebugMode) {
236 debugPrint('✅ Vote created: ${response.uri}');
237 }
238
239 return response;
240 } on Exception catch (e) {
241 throw ApiException('Failed to create vote: $e');
242 }
243 }
244
245 /// Find existing vote for a post
246 ///
247 /// Queries the user's PDS to check if they've already voted on this post.
248 /// Uses cursor-based pagination to search through all vote records, not just
249 /// the first 100. This prevents duplicate votes when users have voted on
250 /// more than 100 posts.
251 ///
252 /// Returns ExistingVote with direction and rkey if found, null otherwise.
253 Future<ExistingVote?> _findExistingVote({
254 required String userDid,
255 required String postUri,
256 }) async {
257 try {
258 final session = await _sessionGetter?.call();
259 if (session == null) {
260 return null;
261 }
262
263 // Paginate through all vote records using cursor
264 String? cursor;
265 const pageSize = 100;
266
267 do {
268 // Build URL with cursor if available
269 final url =
270 cursor == null
271 ? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true'
272 : '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true&cursor=$cursor';
273
274 final response = await session.fetchHandler(url, method: 'GET');
275
276 if (response.statusCode != 200) {
277 if (kDebugMode) {
278 debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
279 }
280 return null;
281 }
282
283 final data = jsonDecode(response.body) as Map<String, dynamic>;
284 final records = data['records'] as List<dynamic>?;
285
286 // Search current page for matching vote
287 if (records != null) {
288 for (final record in records) {
289 final recordMap = record as Map<String, dynamic>;
290 final value = recordMap['value'] as Map<String, dynamic>?;
291
292 if (value == null) {
293 continue;
294 }
295
296 final subject = value['subject'] as Map<String, dynamic>?;
297 if (subject == null) {
298 continue;
299 }
300
301 final subjectUri = subject['uri'] as String?;
302 if (subjectUri == postUri) {
303 // Found existing vote!
304 final direction = value['direction'] as String;
305 final uri = recordMap['uri'] as String;
306
307 // Extract rkey from URI
308 // Format: at://did:plc:xyz/social.coves.feed.vote/3kby...
309 final rkey = uri.split('/').last;
310
311 return ExistingVote(direction: direction, rkey: rkey);
312 }
313 }
314 }
315
316 // Get cursor for next page
317 cursor = data['cursor'] as String?;
318 } while (cursor != null);
319
320 // Vote not found after searching all pages
321 return null;
322 } on Exception catch (e) {
323 if (kDebugMode) {
324 debugPrint('⚠️ Failed to list votes: $e');
325 }
326 // Return null on error - assume no existing vote
327 return null;
328 }
329 }
330
331 /// Create vote record on PDS
332 ///
333 /// Calls com.atproto.repo.createRecord with the vote record.
334 Future<VoteResponse> _createVote({
335 required String userDid,
336 required String postUri,
337 required String postCid,
338 required String direction,
339 }) async {
340 final session = await _sessionGetter?.call();
341 if (session == null) {
342 throw ApiException('User not authenticated - no session available');
343 }
344
345 // Build the vote record according to the lexicon
346 final record = {
347 r'$type': voteCollection,
348 'subject': {'uri': postUri, 'cid': postCid},
349 'direction': direction,
350 'createdAt': DateTime.now().toUtc().toIso8601String(),
351 };
352
353 final requestBody = jsonEncode({
354 'repo': userDid,
355 'collection': voteCollection,
356 'record': record,
357 });
358
359 // Use session's fetchHandler for DPoP-authenticated request
360 final response = await session.fetchHandler(
361 '/xrpc/com.atproto.repo.createRecord',
362 method: 'POST',
363 headers: {'Content-Type': 'application/json'},
364 body: requestBody,
365 );
366
367 if (response.statusCode != 200) {
368 throw ApiException(
369 'Failed to create vote: ${response.statusCode} - ${response.body}',
370 statusCode: response.statusCode,
371 );
372 }
373
374 final data = jsonDecode(response.body) as Map<String, dynamic>;
375 final uri = data['uri'] as String?;
376 final cid = data['cid'] as String?;
377
378 if (uri == null || cid == null) {
379 throw ApiException('Invalid response from PDS - missing uri or cid');
380 }
381
382 // Extract rkey from URI
383 final rkey = uri.split('/').last;
384
385 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
386 }
387
388 /// Delete vote record from PDS
389 ///
390 /// Calls com.atproto.repo.deleteRecord to remove the vote.
391 Future<void> _deleteVote({
392 required String userDid,
393 required String rkey,
394 }) async {
395 final session = await _sessionGetter?.call();
396 if (session == null) {
397 throw ApiException('User not authenticated - no session available');
398 }
399
400 final requestBody = jsonEncode({
401 'repo': userDid,
402 'collection': voteCollection,
403 'rkey': rkey,
404 });
405
406 // Use session's fetchHandler for DPoP-authenticated request
407 final response = await session.fetchHandler(
408 '/xrpc/com.atproto.repo.deleteRecord',
409 method: 'POST',
410 headers: {'Content-Type': 'application/json'},
411 body: requestBody,
412 );
413
414 if (response.statusCode != 200) {
415 throw ApiException(
416 'Failed to delete vote: ${response.statusCode} - ${response.body}',
417 statusCode: response.statusCode,
418 );
419 }
420 }
421}
422
423/// Vote Response
424///
425/// Response from createVote operation.
426class VoteResponse {
427 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted});
428
429 /// AT-URI of the created vote record
430 final String? uri;
431
432 /// Content ID of the vote record
433 final String? cid;
434
435 /// Record key (rkey) of the vote - last segment of URI
436 final String? rkey;
437
438 /// Whether the vote was deleted (toggled off)
439 final bool deleted;
440}
441
442/// Existing Vote
443///
444/// Represents a vote that already exists on the PDS.
445class ExistingVote {
446 const ExistingVote({required this.direction, required this.rkey});
447
448 /// Vote direction ("up" or "down")
449 final String direction;
450
451 /// Record key for deletion
452 final String rkey;
453}
454
455/// Vote Info
456///
457/// Information about a user's vote on a post, returned from getUserVotes().
458class VoteInfo {
459 const VoteInfo({
460 required this.direction,
461 required this.voteUri,
462 required this.rkey,
463 });
464
465 /// Vote direction ("up" or "down")
466 final String direction;
467
468 /// AT-URI of the vote record
469 final String voteUri;
470
471 /// Record key (rkey) - last segment of URI
472 final String rkey;
473}