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}