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}