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}