Main coves client
1import 'dart:async' show Timer, unawaited;
2
3import 'package:characters/characters.dart';
4import 'package:flutter/foundation.dart';
5import '../models/comment.dart';
6import '../services/api_exceptions.dart';
7import '../services/comment_service.dart';
8import '../services/coves_api_service.dart';
9import 'auth_provider.dart';
10import 'vote_provider.dart';
11
12/// Comments Provider
13///
14/// Manages comment state and fetching logic for a specific post.
15/// Supports sorting (hot/top/new), pagination, and vote integration.
16///
17/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
18/// tokens before each authenticated request (critical for atProto OAuth
19/// token rotation).
20class CommentsProvider with ChangeNotifier {
21 CommentsProvider(
22 this._authProvider, {
23 CovesApiService? apiService,
24 VoteProvider? voteProvider,
25 CommentService? commentService,
26 }) : _voteProvider = voteProvider,
27 _commentService = commentService {
28 // Use injected service (for testing) or create new one (for production)
29 // Pass token getter, refresh handler, and sign out handler to API service
30 // for automatic fresh token retrieval and automatic token refresh on 401
31 _apiService =
32 apiService ??
33 CovesApiService(
34 tokenGetter: _authProvider.getAccessToken,
35 tokenRefresher: _authProvider.refreshToken,
36 signOutHandler: _authProvider.signOut,
37 );
38
39 // Track initial auth state
40 _wasAuthenticated = _authProvider.isAuthenticated;
41
42 // Listen to auth state changes and clear comments on sign-out
43 _authProvider.addListener(_onAuthChanged);
44 }
45
46 /// Maximum comment length in characters (matches backend limit)
47 /// Note: This counts Unicode grapheme clusters, so emojis count correctly
48 static const int maxCommentLength = 10000;
49
50 /// Handle authentication state changes
51 ///
52 /// Clears comment state when user signs out to prevent privacy issues.
53 void _onAuthChanged() {
54 final isAuthenticated = _authProvider.isAuthenticated;
55
56 // Only clear if transitioning from authenticated → unauthenticated
57 if (_wasAuthenticated && !isAuthenticated && _comments.isNotEmpty) {
58 if (kDebugMode) {
59 debugPrint('🔒 User signed out - clearing comments');
60 }
61 reset();
62 }
63
64 // Update tracked state
65 _wasAuthenticated = isAuthenticated;
66 }
67
68 final AuthProvider _authProvider;
69 late final CovesApiService _apiService;
70 final VoteProvider? _voteProvider;
71 final CommentService? _commentService;
72
73 // Track previous auth state to detect transitions
74 bool _wasAuthenticated = false;
75
76 // Comment state
77 List<ThreadViewComment> _comments = [];
78 bool _isLoading = false;
79 bool _isLoadingMore = false;
80 String? _error;
81 String? _cursor;
82 bool _hasMore = true;
83
84 // Collapsed thread state - stores URIs of collapsed comments
85 final Set<String> _collapsedComments = {};
86
87 // Current post being viewed
88 String? _postUri;
89 String? _postCid;
90
91 // Comment configuration
92 String _sort = 'hot';
93 String? _timeframe;
94
95 // Flag to track if a refresh should be scheduled after current load
96 bool _pendingRefresh = false;
97
98 // Time update mechanism for periodic UI refreshes
99 Timer? _timeUpdateTimer;
100 final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null);
101
102 // Getters
103 List<ThreadViewComment> get comments => _comments;
104 bool get isLoading => _isLoading;
105 bool get isLoadingMore => _isLoadingMore;
106 String? get error => _error;
107 bool get hasMore => _hasMore;
108 String get sort => _sort;
109 String? get timeframe => _timeframe;
110 ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
111 Set<String> get collapsedComments => _collapsedComments;
112
113 /// Toggle collapsed state for a comment thread
114 ///
115 /// When collapsed, the comment's replies are hidden from view.
116 /// Long-pressing the same comment again will expand the thread.
117 void toggleCollapsed(String uri) {
118 if (_collapsedComments.contains(uri)) {
119 _collapsedComments.remove(uri);
120 } else {
121 _collapsedComments.add(uri);
122 }
123 notifyListeners();
124 }
125
126 /// Check if a specific comment is collapsed
127 bool isCollapsed(String uri) => _collapsedComments.contains(uri);
128
129 /// Start periodic time updates for "time ago" strings
130 ///
131 /// Updates currentTime every minute to trigger UI rebuilds for
132 /// comment timestamps. This ensures "5m ago" updates to "6m ago" without
133 /// requiring user interaction.
134 ///
135 /// Uses ValueNotifier to avoid triggering full provider rebuilds.
136 void startTimeUpdates() {
137 // Cancel existing timer if any
138 _timeUpdateTimer?.cancel();
139
140 // Update current time immediately
141 _currentTimeNotifier.value = DateTime.now();
142
143 // Set up periodic updates (every minute)
144 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
145 _currentTimeNotifier.value = DateTime.now();
146 });
147
148 if (kDebugMode) {
149 debugPrint('⏰ Started periodic time updates for comment timestamps');
150 }
151 }
152
153 /// Stop periodic time updates
154 void stopTimeUpdates() {
155 _timeUpdateTimer?.cancel();
156 _timeUpdateTimer = null;
157 _currentTimeNotifier.value = null;
158
159 if (kDebugMode) {
160 debugPrint('⏰ Stopped periodic time updates');
161 }
162 }
163
164 /// Load comments for a specific post
165 ///
166 /// Parameters:
167 /// - [postUri]: AT-URI of the post
168 /// - [postCid]: CID of the post (needed for creating comments)
169 /// - [refresh]: Whether to refresh from the beginning
170 Future<void> loadComments({
171 required String postUri,
172 required String postCid,
173 bool refresh = false,
174 }) async {
175 // If loading for a different post, reset state
176 if (postUri != _postUri) {
177 reset();
178 _postUri = postUri;
179 _postCid = postCid;
180 }
181
182 // If already loading, schedule a refresh to happen after current load
183 if (_isLoading || _isLoadingMore) {
184 if (refresh) {
185 _pendingRefresh = true;
186 if (kDebugMode) {
187 debugPrint(
188 '⏳ Load in progress - scheduled refresh for after completion',
189 );
190 }
191 }
192 return;
193 }
194
195 try {
196 if (refresh) {
197 _isLoading = true;
198 _error = null;
199 _pendingRefresh = false; // Clear any pending refresh
200 } else {
201 _isLoadingMore = true;
202 }
203 notifyListeners();
204
205 if (kDebugMode) {
206 debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri');
207 }
208
209 final response = await _apiService.getComments(
210 postUri: postUri,
211 sort: _sort,
212 timeframe: _timeframe,
213 cursor: refresh ? null : _cursor,
214 );
215
216 // Only update state after successful fetch
217 if (refresh) {
218 _comments = response.comments;
219 } else {
220 // Create new list instance to trigger rebuilds
221 _comments = [..._comments, ...response.comments];
222 }
223
224 _cursor = response.cursor;
225 _hasMore = response.cursor != null;
226 _error = null;
227
228 if (kDebugMode) {
229 debugPrint('✅ Comments loaded: ${_comments.length} comments total');
230 }
231
232 // Initialize vote state from viewer data in comments response
233 if (_authProvider.isAuthenticated && _voteProvider != null) {
234 if (refresh) {
235 // On refresh, initialize all comments - server data is truth
236 _comments.forEach(_initializeCommentVoteState);
237 } else {
238 // On pagination, only initialize newly fetched comments to avoid
239 // overwriting optimistic vote state on existing comments
240 response.comments.forEach(_initializeCommentVoteState);
241 }
242 }
243
244 // Start time updates when comments are loaded
245 if (_comments.isNotEmpty && _timeUpdateTimer == null) {
246 startTimeUpdates();
247 }
248 } on Exception catch (e) {
249 _error = e.toString();
250 if (kDebugMode) {
251 debugPrint('❌ Failed to fetch comments: $e');
252 }
253 } finally {
254 _isLoading = false;
255 _isLoadingMore = false;
256 notifyListeners();
257
258 // If a refresh was scheduled during this load, execute it now
259 if (_pendingRefresh && _postUri != null) {
260 if (kDebugMode) {
261 debugPrint('🔄 Executing pending refresh');
262 }
263 _pendingRefresh = false;
264 // Schedule refresh without awaiting to avoid blocking
265 // This is intentional - we want the refresh to happen asynchronously
266 unawaited(
267 loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true),
268 );
269 }
270 }
271 }
272
273 /// Refresh comments (pull-to-refresh)
274 ///
275 /// Reloads comments from the beginning for the current post.
276 Future<void> refreshComments() async {
277 if (_postUri == null || _postCid == null) {
278 if (kDebugMode) {
279 debugPrint('⚠️ Cannot refresh - no post loaded');
280 }
281 return;
282 }
283 await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
284 }
285
286 /// Load more comments (pagination)
287 Future<void> loadMoreComments() async {
288 if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) {
289 return;
290 }
291 await loadComments(postUri: _postUri!, postCid: _postCid!);
292 }
293
294 /// Change sort order
295 ///
296 /// Updates the sort option and triggers a refresh of comments.
297 /// Available options: 'hot', 'top', 'new'
298 ///
299 /// Returns true if sort change succeeded, false if reload failed.
300 /// On failure, reverts to previous sort option.
301 Future<bool> setSortOption(String newSort) async {
302 if (_sort == newSort) {
303 return true;
304 }
305
306 final previousSort = _sort;
307 _sort = newSort;
308 notifyListeners();
309
310 // Reload comments with new sort
311 if (_postUri != null && _postCid != null) {
312 try {
313 await loadComments(
314 postUri: _postUri!,
315 postCid: _postCid!,
316 refresh: true,
317 );
318 return true;
319 } on Exception catch (e) {
320 // Revert to previous sort option on failure
321 _sort = previousSort;
322 notifyListeners();
323
324 if (kDebugMode) {
325 debugPrint('Failed to apply sort option: $e');
326 }
327
328 return false;
329 }
330 }
331
332 return true;
333 }
334
335 /// Vote on a comment
336 ///
337 /// Delegates to VoteProvider for optimistic updates and API calls.
338 /// The VoteProvider handles:
339 /// - Optimistic UI updates
340 /// - API call to user's PDS
341 /// - Rollback on error
342 ///
343 /// Parameters:
344 /// - [commentUri]: AT-URI of the comment
345 /// - [commentCid]: Content ID of the comment
346 /// - [voteType]: Vote direction ('up' or 'down')
347 ///
348 /// Returns:
349 /// - true if vote was created
350 /// - false if vote was removed (toggled off)
351 Future<bool> voteOnComment({
352 required String commentUri,
353 required String commentCid,
354 String voteType = 'up',
355 }) async {
356 if (_voteProvider == null) {
357 throw Exception('VoteProvider not available');
358 }
359
360 try {
361 final result = await _voteProvider.toggleVote(
362 postUri: commentUri,
363 postCid: commentCid,
364 direction: voteType,
365 );
366
367 if (kDebugMode) {
368 debugPrint('✅ Comment vote ${result ? 'created' : 'removed'}');
369 }
370
371 return result;
372 } on Exception catch (e) {
373 if (kDebugMode) {
374 debugPrint('❌ Failed to vote on comment: $e');
375 }
376 rethrow;
377 }
378 }
379
380 /// Create a comment on the current post or as a reply to another comment
381 ///
382 /// Parameters:
383 /// - [content]: The comment text content
384 /// - [parentComment]: Optional parent comment for nested replies.
385 /// If null, this is a top-level reply to the post.
386 ///
387 /// The reply reference structure:
388 /// - Root: Always points to the original post (_postUri, _postCid)
389 /// - Parent: Points to the post (top-level) or the parent comment (nested)
390 ///
391 /// After successful creation, refreshes the comments list.
392 ///
393 /// Throws:
394 /// - ValidationException if content is empty or too long
395 /// - ApiException if CommentService is not available or no post is loaded
396 /// - ApiException for API errors
397 Future<void> createComment({
398 required String content,
399 ThreadViewComment? parentComment,
400 }) async {
401 // Validate content
402 final trimmedContent = content.trim();
403 if (trimmedContent.isEmpty) {
404 throw ValidationException('Comment cannot be empty');
405 }
406
407 // Use characters.length for proper Unicode/emoji counting
408 final charCount = trimmedContent.characters.length;
409 if (charCount > maxCommentLength) {
410 throw ValidationException(
411 'Comment too long ($charCount characters). '
412 'Maximum is $maxCommentLength characters.',
413 );
414 }
415
416 if (_commentService == null) {
417 throw ApiException('CommentService not available');
418 }
419
420 if (_postUri == null || _postCid == null) {
421 throw ApiException('No post loaded - cannot create comment');
422 }
423
424 // Root is always the original post
425 final rootUri = _postUri!;
426 final rootCid = _postCid!;
427
428 // Parent depends on whether this is a top-level or nested reply
429 final String parentUri;
430 final String parentCid;
431
432 if (parentComment != null) {
433 // Nested reply - parent is the comment being replied to
434 parentUri = parentComment.comment.uri;
435 parentCid = parentComment.comment.cid;
436 } else {
437 // Top-level reply - parent is the post
438 parentUri = rootUri;
439 parentCid = rootCid;
440 }
441
442 if (kDebugMode) {
443 debugPrint('💬 Creating comment');
444 debugPrint(' Root: $rootUri');
445 debugPrint(' Parent: $parentUri');
446 debugPrint(' Is nested: ${parentComment != null}');
447 }
448
449 try {
450 final response = await _commentService.createComment(
451 rootUri: rootUri,
452 rootCid: rootCid,
453 parentUri: parentUri,
454 parentCid: parentCid,
455 content: trimmedContent,
456 );
457
458 if (kDebugMode) {
459 debugPrint('✅ Comment created: ${response.uri}');
460 }
461
462 // Refresh comments to show the new comment
463 await refreshComments();
464 } on Exception catch (e) {
465 if (kDebugMode) {
466 debugPrint('❌ Failed to create comment: $e');
467 }
468 rethrow;
469 }
470 }
471
472 /// Initialize vote state for a comment and its replies recursively
473 ///
474 /// Extracts viewer vote data from comment and initializes VoteProvider state.
475 /// Handles nested replies recursively.
476 ///
477 /// IMPORTANT: Always calls setInitialVoteState, even when viewer.vote is
478 /// null. This ensures that if a user removed their vote on another device,
479 /// the local state is cleared on refresh.
480 void _initializeCommentVoteState(ThreadViewComment threadComment) {
481 final viewer = threadComment.comment.viewer;
482 _voteProvider!.setInitialVoteState(
483 postUri: threadComment.comment.uri,
484 voteDirection: viewer?.vote,
485 voteUri: viewer?.voteUri,
486 );
487
488 // Recursively initialize vote state for replies
489 threadComment.replies?.forEach(_initializeCommentVoteState);
490 }
491
492 /// Retry loading after error
493 Future<void> retry() async {
494 _error = null;
495 if (_postUri != null && _postCid != null) {
496 await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
497 }
498 }
499
500 /// Clear error
501 void clearError() {
502 _error = null;
503 notifyListeners();
504 }
505
506 /// Reset comment state
507 void reset() {
508 _comments = [];
509 _cursor = null;
510 _hasMore = true;
511 _error = null;
512 _isLoading = false;
513 _isLoadingMore = false;
514 _postUri = null;
515 _postCid = null;
516 _pendingRefresh = false;
517 _collapsedComments.clear();
518 notifyListeners();
519 }
520
521 @override
522 void dispose() {
523 // Stop time updates and cancel timer (also sets value to null)
524 stopTimeUpdates();
525 // Remove auth listener to prevent memory leaks
526 _authProvider.removeListener(_onAuthChanged);
527 _apiService.dispose();
528 // Dispose the ValueNotifier last
529 _currentTimeNotifier.dispose();
530 super.dispose();
531 }
532}