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