···
1
-
import 'dart:convert';
3
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
1
+
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
4
+
import '../config/environment_config.dart';
5
+
import '../models/coves_session.dart';
import 'api_exceptions.dart';
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.
10
+
/// Handles vote/like interactions through the Coves backend.
12
+
/// **Architecture with Backend OAuth**:
13
+
/// With sealed tokens, the client cannot write directly to the user's PDS
14
+
/// (no DPoP keys available). Instead, votes go through the Coves backend:
14
-
/// **Correct Architecture**:
15
-
/// Mobile Client → User's PDS (com.atproto.repo.createRecord)
19
-
/// Backend AppView (indexes vote events)
16
+
/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
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)
19
+
/// 1. Unseals the token to get the actual access/refresh tokens
20
+
/// 2. Uses stored DPoP keys to sign requests
21
+
/// 3. Writes to the user's PDS on their behalf
26
-
/// **DPoP Authentication**:
27
-
/// atProto PDSs require DPoP (Demonstrating Proof of Possession)
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
23
+
/// TODO: Backend vote endpoints need to be implemented:
24
+
/// - POST /xrpc/social.coves.feed.vote.create
25
+
/// - POST /xrpc/social.coves.feed.vote.delete
26
+
/// - GET /xrpc/social.coves.feed.vote.list (or included in feed response)
36
-
Future<OAuthSession?> Function()? sessionGetter,
29
+
Future<CovesSession?> Function()? sessionGetter,
String? Function()? didGetter,
38
-
String? Function()? pdsUrlGetter,
31
+
Future<bool> Function()? tokenRefresher,
32
+
Future<void> Function()? signOutHandler,
}) : _sessionGetter = sessionGetter,
41
-
_pdsUrlGetter = pdsUrlGetter;
36
+
_tokenRefresher = tokenRefresher,
37
+
_signOutHandler = signOutHandler {
41
+
baseUrl: EnvironmentConfig.current.apiUrl,
42
+
connectTimeout: const Duration(seconds: 30),
43
+
receiveTimeout: const Duration(seconds: 30),
44
+
headers: {'Content-Type': 'application/json'},
48
+
// Add 401 retry interceptor (same pattern as CovesApiService)
49
+
_dio.interceptors.add(
50
+
InterceptorsWrapper(
51
+
onRequest: (options, handler) async {
52
+
// Fetch fresh token before each request
53
+
final session = await _sessionGetter?.call();
54
+
if (session != null) {
55
+
options.headers['Authorization'] = 'Bearer ${session.token}';
57
+
debugPrint('🔐 VoteService: Adding fresh Authorization header');
62
+
'⚠️ VoteService: Session getter returned null - '
63
+
'making unauthenticated request',
67
+
return handler.next(options);
69
+
onError: (error, handler) async {
70
+
// Handle 401 errors with automatic token refresh
71
+
if (error.response?.statusCode == 401 && _tokenRefresher != null) {
73
+
debugPrint('🔄 VoteService: 401 detected, attempting token refresh...');
43
-
final Future<OAuthSession?> Function()? _sessionGetter;
76
+
// Check if we already retried this request (prevent infinite loop)
77
+
if (error.requestOptions.extra['retried'] == true) {
80
+
'⚠️ VoteService: Request already retried after token refresh, '
84
+
// Already retried once, don't retry again
85
+
if (_signOutHandler != null) {
86
+
await _signOutHandler();
88
+
return handler.next(error);
92
+
// Attempt to refresh the token
93
+
final refreshSucceeded = await _tokenRefresher();
95
+
if (refreshSucceeded) {
97
+
debugPrint('✅ VoteService: Token refresh successful, retrying request');
100
+
// Get the new session
101
+
final newSession = await _sessionGetter?.call();
103
+
if (newSession != null) {
104
+
// Mark this request as retried to prevent infinite loops
105
+
error.requestOptions.extra['retried'] = true;
107
+
// Update the Authorization header with the new token
108
+
error.requestOptions.headers['Authorization'] =
109
+
'Bearer ${newSession.token}';
111
+
// Retry the original request with the new token
113
+
final response = await _dio.fetch(error.requestOptions);
114
+
return handler.resolve(response);
115
+
} on DioException catch (retryError) {
116
+
// If retry failed with 401 and already retried, we already
117
+
// signed out in the retry limit check above, so just pass
118
+
// the error through without signing out again
119
+
if (retryError.response?.statusCode == 401 &&
120
+
retryError.requestOptions.extra['retried'] == true) {
121
+
return handler.next(retryError);
123
+
// For other errors during retry, rethrow to outer catch
129
+
// Refresh failed, sign out the user
131
+
debugPrint('❌ VoteService: Token refresh failed, signing out user');
133
+
if (_signOutHandler != null) {
134
+
await _signOutHandler();
138
+
debugPrint('❌ VoteService: Error during token refresh: $e');
140
+
// Only sign out if we haven't already (avoid double sign-out)
141
+
// Check if this is a DioException from a retried request
142
+
final isRetriedRequest = e is DioException &&
143
+
e.response?.statusCode == 401 &&
144
+
e.requestOptions.extra['retried'] == true;
146
+
if (!isRetriedRequest && _signOutHandler != null) {
147
+
await _signOutHandler();
152
+
// Log the error for debugging
154
+
debugPrint('❌ VoteService API Error: ${error.message}');
155
+
if (error.response != null) {
156
+
debugPrint(' Status: ${error.response?.statusCode}');
157
+
debugPrint(' Data: ${error.response?.data}');
160
+
return handler.next(error);
166
+
final Future<CovesSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
45
-
final String? Function()? _pdsUrlGetter;
168
+
final Future<bool> Function()? _tokenRefresher;
169
+
final Future<void> Function()? _signOutHandler;
170
+
late final Dio _dio;
/// Collection name for vote records
static const String voteCollection = 'social.coves.feed.vote';
/// Get all votes for the current user
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.
177
+
/// TODO: This needs a backend endpoint to list user's votes.
178
+
/// For now, returns empty map - votes will be fetched with feed data.
/// - `Map<String, VoteInfo>` where key is the post URI
···
71
-
final votes = <String, VoteInfo>{};
74
-
// Paginate through all vote records
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';
81
-
final response = await session.fetchHandler(url);
83
-
if (response.statusCode != 200) {
85
-
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
90
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
91
-
final records = data['records'] as List<dynamic>?;
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?;
99
-
if (value == null || uri == null) {
103
-
final subject = value['subject'] as Map<String, dynamic>?;
104
-
final direction = value['direction'] as String?;
106
-
if (subject == null || direction == null) {
110
-
final subjectUri = subject['uri'] as String?;
111
-
if (subjectUri != null) {
112
-
// Extract rkey from vote URI
113
-
final rkey = uri.split('/').last;
115
-
votes[subjectUri] = VoteInfo(
116
-
direction: direction,
124
-
cursor = data['cursor'] as String?;
125
-
} while (cursor != null);
195
+
// TODO: Implement backend endpoint for listing user votes
196
+
// For now, vote state should come from feed responses
128
-
debugPrint('📊 Loaded ${votes.length} votes from PDS');
199
+
'⚠️ getUserVotes: Backend endpoint not yet implemented. '
200
+
'Vote state should come from feed responses.',
} on Exception catch (e) {
134
-
debugPrint('⚠️ Failed to load user votes: $e');
207
+
debugPrint('⚠️ Failed to load user votes: $e');
···
/// Create or toggle vote
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
215
+
/// Sends vote request to the Coves backend, which proxies to the user's PDS.
149
-
/// - [postUri]: AT-URI of the post (e.g.,
150
-
/// "at://did:plc:xyz/social.coves.post.record/abc123")
218
+
/// - [postUri]: AT-URI of the post
/// - [postCid]: Content ID of the post (for strong reference)
/// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
153
-
/// - [existingVoteRkey]: Optional rkey from cached state (avoids O(n) lookup)
221
+
/// - [existingVoteRkey]: Optional rkey from cached state
/// - [existingVoteDirection]: Optional direction from cached state
···
String? existingVoteDirection,
170
-
// Get user's DID and PDS URL
final userDid = _didGetter?.call();
172
-
final pdsUrl = _pdsUrlGetter?.call();
239
+
final session = await _sessionGetter?.call();
if (userDid == null || userDid.isEmpty) {
throw ApiException('User not authenticated - no DID available');
178
-
if (pdsUrl == null || pdsUrl.isEmpty) {
179
-
throw ApiException('PDS URL not available');
245
+
if (session == null) {
246
+
throw ApiException('User not authenticated - no session available');
183
-
debugPrint('🗳️ Creating vote on PDS');
250
+
debugPrint('🗳️ Creating vote via backend');
debugPrint(' Post: $postUri');
debugPrint(' Direction: $direction');
186
-
debugPrint(' PDS: $pdsUrl');
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,
255
+
// Determine if this is a toggle (delete) or create
256
+
final isToggleOff =
257
+
existingVoteRkey != null && existingVoteDirection == direction;
260
+
// Delete existing vote
261
+
return _deleteVote(
198
-
debugPrint(' Using cached vote state (avoiding PDS lookup)');
201
-
existingVote = await _findExistingVote(
207
-
if (existingVote != null) {
267
+
// If switching direction, delete old vote first
268
+
if (existingVoteRkey != null && existingVoteDirection != null) {
209
-
debugPrint(' Found existing vote: ${existingVote.direction}');
270
+
debugPrint(' Switching vote direction - deleting old vote first');
212
-
// If same direction, toggle off (delete)
213
-
if (existingVote.direction == direction) {
215
-
debugPrint(' Same direction - deleting vote');
217
-
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
218
-
return const VoteResponse(deleted: true);
221
-
// Different direction - delete old vote first
223
-
debugPrint(' Different direction - switching vote');
225
-
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
272
+
await _deleteVote(session: session, rkey: existingVoteRkey);
228
-
// Step 2: Create new vote
229
-
final response = await _createVote(
233
-
direction: direction,
275
+
// Create new vote via backend
276
+
// Note: Authorization header is added by the interceptor
277
+
final response = await _dio.post<Map<String, dynamic>>(
278
+
'/xrpc/social.coves.feed.vote.create',
284
+
'direction': direction,
237
-
debugPrint('✅ Vote created: ${response.uri}');
288
+
final data = response.data;
289
+
if (data == null) {
290
+
throw ApiException('Invalid response from server - no data');
241
-
} on Exception catch (e) {
242
-
throw ApiException('Failed to create vote: $e');
293
+
final uri = data['uri'] as String?;
294
+
final cid = data['cid'] as String?;
246
-
/// Find existing vote for a post
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.
253
-
/// Returns ExistingVote with direction and rkey if found, null otherwise.
254
-
Future<ExistingVote?> _findExistingVote({
255
-
required String userDid,
256
-
required String postUri,
259
-
final session = await _sessionGetter?.call();
260
-
if (session == null) {
296
+
if (uri == null || cid == null) {
297
+
throw ApiException('Invalid response from server - missing uri or cid');
264
-
// Paginate through all vote records using cursor
266
-
const pageSize = 100;
269
-
// Build URL with cursor if available
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';
275
-
final response = await session.fetchHandler(url);
277
-
if (response.statusCode != 200) {
279
-
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
284
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
285
-
final records = data['records'] as List<dynamic>?;
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>?;
300
+
// Extract rkey from URI
301
+
final rkey = uri.split('/').last;
293
-
if (value == null) {
304
+
debugPrint('✅ Vote created: $uri');
297
-
final subject = value['subject'] as Map<String, dynamic>?;
298
-
if (subject == null) {
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;
308
-
// Extract rkey from URI
309
-
// Format: at://did:plc:xyz/social.coves.feed.vote/3kby...
310
-
final rkey = uri.split('/').last;
312
-
return ExistingVote(direction: direction, rkey: rkey);
317
-
// Get cursor for next page
318
-
cursor = data['cursor'] as String?;
319
-
} while (cursor != null);
321
-
// Vote not found after searching all pages
323
-
} on Exception catch (e) {
307
+
return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
308
+
} on DioException catch (e) {
325
-
debugPrint('⚠️ Failed to list votes: $e');
310
+
debugPrint('❌ Vote failed: ${e.message}');
311
+
debugPrint(' Status: ${e.response?.statusCode}');
312
+
debugPrint(' Data: ${e.response?.data}');
327
-
// Return null on error - assume no existing vote
332
-
/// Create vote record on PDS
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,
341
-
final session = await _sessionGetter?.call();
342
-
if (session == null) {
343
-
throw ApiException('User not authenticated - no session available');
346
-
// Build the vote record according to the lexicon
348
-
r'$type': voteCollection,
349
-
'subject': {'uri': postUri, 'cid': postCid},
350
-
'direction': direction,
351
-
'createdAt': DateTime.now().toUtc().toIso8601String(),
354
-
final requestBody = jsonEncode({
356
-
'collection': voteCollection,
360
-
// Use session's fetchHandler for DPoP-authenticated request
361
-
final response = await session.fetchHandler(
362
-
'/xrpc/com.atproto.repo.createRecord',
364
-
headers: {'Content-Type': 'application/json'},
315
+
if (e.response?.statusCode == 401) {
316
+
throw AuthenticationException(
317
+
'Authentication failed. Please sign in again.',
368
-
if (response.statusCode != 200) {
370
-
'Failed to create vote: ${response.statusCode} - ${response.body}',
371
-
statusCode: response.statusCode,
323
+
'Failed to create vote: ${e.message}',
324
+
statusCode: e.response?.statusCode,
375
-
final data = jsonDecode(response.body) as Map<String, dynamic>;
376
-
final uri = data['uri'] as String?;
377
-
final cid = data['cid'] as String?;
379
-
if (uri == null || cid == null) {
380
-
throw ApiException('Invalid response from PDS - missing uri or cid');
327
+
} on Exception catch (e) {
328
+
throw ApiException('Failed to create vote: $e');
383
-
// Extract rkey from URI
384
-
final rkey = uri.split('/').last;
386
-
return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
389
-
/// Delete vote record from PDS
391
-
/// Calls com.atproto.repo.deleteRecord to remove the vote.
392
-
Future<void> _deleteVote({
393
-
required String userDid,
332
+
/// Delete vote via backend
333
+
Future<VoteResponse> _deleteVote({
334
+
required CovesSession session,
396
-
final session = await _sessionGetter?.call();
397
-
if (session == null) {
398
-
throw ApiException('User not authenticated - no session available');
338
+
// Note: Authorization header is added by the interceptor
339
+
await _dio.post<void>(
340
+
'/xrpc/social.coves.feed.vote.delete',
401
-
final requestBody = jsonEncode({
403
-
'collection': voteCollection,
347
+
debugPrint('✅ Vote deleted');
407
-
// Use session's fetchHandler for DPoP-authenticated request
408
-
final response = await session.fetchHandler(
409
-
'/xrpc/com.atproto.repo.deleteRecord',
411
-
headers: {'Content-Type': 'application/json'},
350
+
return const VoteResponse(deleted: true);
351
+
} on DioException catch (e) {
353
+
debugPrint('❌ Delete vote failed: ${e.message}');
415
-
if (response.statusCode != 200) {
417
-
'Failed to delete vote: ${response.statusCode} - ${response.body}',
418
-
statusCode: response.statusCode,
357
+
'Failed to delete vote: ${e.message}',
358
+
statusCode: e.response?.statusCode,