···
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
import 'api_exceptions.dart';
-
/// Handles vote/like interactions by writing directly to the user's PDS.
-
/// This follows the atProto architecture where clients write to PDSs and
-
/// AppViews only index public data.
-
/// **Correct Architecture**:
-
/// Mobile Client → User's PDS (com.atproto.repo.createRecord)
-
/// Backend AppView (indexes vote events)
-
/// Uses these XRPC endpoints:
-
/// - com.atproto.repo.createRecord (create vote)
-
/// - com.atproto.repo.deleteRecord (delete vote)
-
/// - com.atproto.repo.listRecords (find existing votes)
-
/// **DPoP Authentication**:
-
/// atProto PDSs require DPoP (Demonstrating Proof of Possession)
-
/// Uses OAuthSession.fetchHandler which automatically handles:
-
/// - Authorization: DPoP `<access_token>`
-
/// - DPoP: `<proof>` (signed JWT proving key possession)
-
/// - Automatic token refresh on expiry
-
/// - Nonce management for replay protection
-
Future<OAuthSession?> Function()? sessionGetter,
String? Function()? didGetter,
-
String? Function()? pdsUrlGetter,
}) : _sessionGetter = sessionGetter,
-
_pdsUrlGetter = pdsUrlGetter;
-
final Future<OAuthSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
-
final String? Function()? _pdsUrlGetter;
/// Collection name for vote records
static const String voteCollection = 'social.coves.feed.vote';
/// Get all votes for the current user
-
/// Queries the user's PDS for all their vote records and returns a map
-
/// of post URI -> vote info. This is used to initialize vote state when
/// - `Map<String, VoteInfo>` where key is the post URI
···
-
final votes = <String, VoteInfo>{};
-
// Paginate through all vote records
-
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100'
-
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&cursor=$cursor';
-
final response = await session.fetchHandler(url);
-
if (response.statusCode != 200) {
-
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
-
final records = data['records'] as List<dynamic>?;
-
for (final record in records) {
-
final recordMap = record as Map<String, dynamic>;
-
final value = recordMap['value'] as Map<String, dynamic>?;
-
final uri = recordMap['uri'] as String?;
-
if (value == null || uri == null) {
-
final subject = value['subject'] as Map<String, dynamic>?;
-
final direction = value['direction'] as String?;
-
if (subject == null || direction == null) {
-
final subjectUri = subject['uri'] as String?;
-
if (subjectUri != null) {
-
// Extract rkey from vote URI
-
final rkey = uri.split('/').last;
-
votes[subjectUri] = VoteInfo(
-
cursor = data['cursor'] as String?;
-
} while (cursor != null);
-
debugPrint('📊 Loaded ${votes.length} votes from PDS');
} on Exception catch (e) {
-
debugPrint('⚠️ Failed to load user votes: $e');
···
/// Create or toggle vote
-
/// Implements smart toggle logic:
-
/// 1. Query PDS for existing vote on this post (or use cached state)
-
/// 2. If exists with same direction → Delete (toggle off)
-
/// 3. If exists with different direction → Delete old + Create new
-
/// 4. If no existing vote → Create new
-
/// - [postUri]: AT-URI of the post (e.g.,
-
/// "at://did:plc:xyz/social.coves.post.record/abc123")
/// - [postCid]: Content ID of the post (for strong reference)
/// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
-
/// - [existingVoteRkey]: Optional rkey from cached state (avoids O(n) lookup)
/// - [existingVoteDirection]: Optional direction from cached state
···
String? existingVoteDirection,
-
// Get user's DID and PDS URL
final userDid = _didGetter?.call();
-
final pdsUrl = _pdsUrlGetter?.call();
if (userDid == null || userDid.isEmpty) {
throw ApiException('User not authenticated - no DID available');
-
if (pdsUrl == null || pdsUrl.isEmpty) {
-
throw ApiException('PDS URL not available');
-
debugPrint('🗳️ Creating vote on PDS');
debugPrint(' Post: $postUri');
debugPrint(' Direction: $direction');
-
debugPrint(' PDS: $pdsUrl');
-
// Step 1: Check for existing vote
-
// Use cached state if available to avoid O(n) PDS lookup
-
ExistingVote? existingVote;
-
if (existingVoteRkey != null && existingVoteDirection != null) {
-
existingVote = ExistingVote(
-
direction: existingVoteDirection,
-
debugPrint(' Using cached vote state (avoiding PDS lookup)');
-
existingVote = await _findExistingVote(
-
if (existingVote != null) {
-
debugPrint(' Found existing vote: ${existingVote.direction}');
-
// If same direction, toggle off (delete)
-
if (existingVote.direction == direction) {
-
debugPrint(' Same direction - deleting vote');
-
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
-
return const VoteResponse(deleted: true);
-
// Different direction - delete old vote first
-
debugPrint(' Different direction - switching vote');
-
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
-
// Step 2: Create new vote
-
final response = await _createVote(
-
debugPrint('✅ Vote created: ${response.uri}');
-
} on Exception catch (e) {
-
throw ApiException('Failed to create vote: $e');
-
/// Find existing vote for a post
-
/// Queries the user's PDS to check if they've already voted on this post.
-
/// Uses cursor-based pagination to search through all vote records, not just
-
/// the first 100. This prevents duplicate votes when users have voted on
-
/// more than 100 posts.
-
/// Returns ExistingVote with direction and rkey if found, null otherwise.
-
Future<ExistingVote?> _findExistingVote({
-
required String userDid,
-
required String postUri,
-
final session = await _sessionGetter?.call();
-
// Paginate through all vote records using cursor
-
// Build URL with cursor if available
-
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true'
-
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true&cursor=$cursor';
-
final response = await session.fetchHandler(url);
-
if (response.statusCode != 200) {
-
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
-
final records = data['records'] as List<dynamic>?;
-
// Search current page for matching vote
-
for (final record in records) {
-
final recordMap = record as Map<String, dynamic>;
-
final value = recordMap['value'] as Map<String, dynamic>?;
-
final subject = value['subject'] as Map<String, dynamic>?;
-
final subjectUri = subject['uri'] as String?;
-
if (subjectUri == postUri) {
-
// Found existing vote!
-
final direction = value['direction'] as String;
-
final uri = recordMap['uri'] as String;
-
// Extract rkey from URI
-
// Format: at://did:plc:xyz/social.coves.feed.vote/3kby...
-
final rkey = uri.split('/').last;
-
return ExistingVote(direction: direction, rkey: rkey);
-
// Get cursor for next page
-
cursor = data['cursor'] as String?;
-
} while (cursor != null);
-
// Vote not found after searching all pages
-
} on Exception catch (e) {
-
debugPrint('⚠️ Failed to list votes: $e');
-
// Return null on error - assume no existing vote
-
/// Create vote record on PDS
-
/// Calls com.atproto.repo.createRecord with the vote record.
-
Future<VoteResponse> _createVote({
-
required String userDid,
-
required String postUri,
-
required String postCid,
-
required String direction,
-
final session = await _sessionGetter?.call();
-
throw ApiException('User not authenticated - no session available');
-
// Build the vote record according to the lexicon
-
r'$type': voteCollection,
-
'subject': {'uri': postUri, 'cid': postCid},
-
'direction': direction,
-
'createdAt': DateTime.now().toUtc().toIso8601String(),
-
final requestBody = jsonEncode({
-
'collection': voteCollection,
-
// Use session's fetchHandler for DPoP-authenticated request
-
final response = await session.fetchHandler(
-
'/xrpc/com.atproto.repo.createRecord',
-
headers: {'Content-Type': 'application/json'},
-
if (response.statusCode != 200) {
-
'Failed to create vote: ${response.statusCode} - ${response.body}',
-
statusCode: response.statusCode,
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
-
final uri = data['uri'] as String?;
-
final cid = data['cid'] as String?;
-
if (uri == null || cid == null) {
-
throw ApiException('Invalid response from PDS - missing uri or cid');
-
// Extract rkey from URI
-
final rkey = uri.split('/').last;
-
return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
-
/// Delete vote record from PDS
-
/// Calls com.atproto.repo.deleteRecord to remove the vote.
-
Future<void> _deleteVote({
-
required String userDid,
-
final session = await _sessionGetter?.call();
-
throw ApiException('User not authenticated - no session available');
-
final requestBody = jsonEncode({
-
'collection': voteCollection,
-
// Use session's fetchHandler for DPoP-authenticated request
-
final response = await session.fetchHandler(
-
'/xrpc/com.atproto.repo.deleteRecord',
-
headers: {'Content-Type': 'application/json'},
-
if (response.statusCode != 200) {
-
'Failed to delete vote: ${response.statusCode} - ${response.body}',
-
statusCode: response.statusCode,