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