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 // Current post being viewed
85 String? _postUri;
86 String? _postCid;
87
88 // Comment configuration
89 String _sort = 'hot';
90 String? _timeframe;
91
92 // Flag to track if a refresh should be scheduled after current load
93 bool _pendingRefresh = false;
94
95 // Time update mechanism for periodic UI refreshes
96 Timer? _timeUpdateTimer;
97 final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null);
98
99 // Getters
100 List<ThreadViewComment> get comments => _comments;
101 bool get isLoading => _isLoading;
102 bool get isLoadingMore => _isLoadingMore;
103 String? get error => _error;
104 bool get hasMore => _hasMore;
105 String get sort => _sort;
106 String? get timeframe => _timeframe;
107 ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
108
109 /// Start periodic time updates for "time ago" strings
110 ///
111 /// Updates currentTime every minute to trigger UI rebuilds for
112 /// comment timestamps. This ensures "5m ago" updates to "6m ago" without
113 /// requiring user interaction.
114 ///
115 /// Uses ValueNotifier to avoid triggering full provider rebuilds.
116 void startTimeUpdates() {
117 // Cancel existing timer if any
118 _timeUpdateTimer?.cancel();
119
120 // Update current time immediately
121 _currentTimeNotifier.value = DateTime.now();
122
123 // Set up periodic updates (every minute)
124 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
125 _currentTimeNotifier.value = DateTime.now();
126 });
127
128 if (kDebugMode) {
129 debugPrint('⏰ Started periodic time updates for comment timestamps');
130 }
131 }
132
133 /// Stop periodic time updates
134 void stopTimeUpdates() {
135 _timeUpdateTimer?.cancel();
136 _timeUpdateTimer = null;
137 _currentTimeNotifier.value = null;
138
139 if (kDebugMode) {
140 debugPrint('⏰ Stopped periodic time updates');
141 }
142 }
143
144 /// Load comments for a specific post
145 ///
146 /// Parameters:
147 /// - [postUri]: AT-URI of the post
148 /// - [postCid]: CID of the post (needed for creating comments)
149 /// - [refresh]: Whether to refresh from the beginning
150 Future<void> loadComments({
151 required String postUri,
152 required String postCid,
153 bool refresh = false,
154 }) async {
155 // If loading for a different post, reset state
156 if (postUri != _postUri) {
157 reset();
158 _postUri = postUri;
159 _postCid = postCid;
160 }
161
162 // If already loading, schedule a refresh to happen after current load
163 if (_isLoading || _isLoadingMore) {
164 if (refresh) {
165 _pendingRefresh = true;
166 if (kDebugMode) {
167 debugPrint(
168 '⏳ Load in progress - scheduled refresh for after completion',
169 );
170 }
171 }
172 return;
173 }
174
175 try {
176 if (refresh) {
177 _isLoading = true;
178 _error = null;
179 _pendingRefresh = false; // Clear any pending refresh
180 } else {
181 _isLoadingMore = true;
182 }
183 notifyListeners();
184
185 if (kDebugMode) {
186 debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri');
187 }
188
189 final response = await _apiService.getComments(
190 postUri: postUri,
191 sort: _sort,
192 timeframe: _timeframe,
193 cursor: refresh ? null : _cursor,
194 );
195
196 // Only update state after successful fetch
197 if (refresh) {
198 _comments = response.comments;
199 } else {
200 // Create new list instance to trigger rebuilds
201 _comments = [..._comments, ...response.comments];
202 }
203
204 _cursor = response.cursor;
205 _hasMore = response.cursor != null;
206 _error = null;
207
208 if (kDebugMode) {
209 debugPrint('✅ Comments loaded: ${_comments.length} comments total');
210 }
211
212 // Initialize vote state from viewer data in comments response
213 if (_authProvider.isAuthenticated && _voteProvider != null) {
214 if (refresh) {
215 // On refresh, initialize all comments - server data is truth
216 _comments.forEach(_initializeCommentVoteState);
217 } else {
218 // On pagination, only initialize newly fetched comments to avoid
219 // overwriting optimistic vote state on existing comments
220 response.comments.forEach(_initializeCommentVoteState);
221 }
222 }
223
224 // Start time updates when comments are loaded
225 if (_comments.isNotEmpty && _timeUpdateTimer == null) {
226 startTimeUpdates();
227 }
228 } on Exception catch (e) {
229 _error = e.toString();
230 if (kDebugMode) {
231 debugPrint('❌ Failed to fetch comments: $e');
232 }
233 } finally {
234 _isLoading = false;
235 _isLoadingMore = false;
236 notifyListeners();
237
238 // If a refresh was scheduled during this load, execute it now
239 if (_pendingRefresh && _postUri != null) {
240 if (kDebugMode) {
241 debugPrint('🔄 Executing pending refresh');
242 }
243 _pendingRefresh = false;
244 // Schedule refresh without awaiting to avoid blocking
245 // This is intentional - we want the refresh to happen asynchronously
246 unawaited(
247 loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true),
248 );
249 }
250 }
251 }
252
253 /// Refresh comments (pull-to-refresh)
254 ///
255 /// Reloads comments from the beginning for the current post.
256 Future<void> refreshComments() async {
257 if (_postUri == null || _postCid == null) {
258 if (kDebugMode) {
259 debugPrint('⚠️ Cannot refresh - no post loaded');
260 }
261 return;
262 }
263 await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
264 }
265
266 /// Load more comments (pagination)
267 Future<void> loadMoreComments() async {
268 if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) {
269 return;
270 }
271 await loadComments(postUri: _postUri!, postCid: _postCid!);
272 }
273
274 /// Change sort order
275 ///
276 /// Updates the sort option and triggers a refresh of comments.
277 /// Available options: 'hot', 'top', 'new'
278 ///
279 /// Returns true if sort change succeeded, false if reload failed.
280 /// On failure, reverts to previous sort option.
281 Future<bool> setSortOption(String newSort) async {
282 if (_sort == newSort) {
283 return true;
284 }
285
286 final previousSort = _sort;
287 _sort = newSort;
288 notifyListeners();
289
290 // Reload comments with new sort
291 if (_postUri != null && _postCid != null) {
292 try {
293 await loadComments(
294 postUri: _postUri!,
295 postCid: _postCid!,
296 refresh: true,
297 );
298 return true;
299 } on Exception catch (e) {
300 // Revert to previous sort option on failure
301 _sort = previousSort;
302 notifyListeners();
303
304 if (kDebugMode) {
305 debugPrint('Failed to apply sort option: $e');
306 }
307
308 return false;
309 }
310 }
311
312 return true;
313 }
314
315 /// Vote on a comment
316 ///
317 /// Delegates to VoteProvider for optimistic updates and API calls.
318 /// The VoteProvider handles:
319 /// - Optimistic UI updates
320 /// - API call to user's PDS
321 /// - Rollback on error
322 ///
323 /// Parameters:
324 /// - [commentUri]: AT-URI of the comment
325 /// - [commentCid]: Content ID of the comment
326 /// - [voteType]: Vote direction ('up' or 'down')
327 ///
328 /// Returns:
329 /// - true if vote was created
330 /// - false if vote was removed (toggled off)
331 Future<bool> voteOnComment({
332 required String commentUri,
333 required String commentCid,
334 String voteType = 'up',
335 }) async {
336 if (_voteProvider == null) {
337 throw Exception('VoteProvider not available');
338 }
339
340 try {
341 final result = await _voteProvider.toggleVote(
342 postUri: commentUri,
343 postCid: commentCid,
344 direction: voteType,
345 );
346
347 if (kDebugMode) {
348 debugPrint('✅ Comment vote ${result ? 'created' : 'removed'}');
349 }
350
351 return result;
352 } on Exception catch (e) {
353 if (kDebugMode) {
354 debugPrint('❌ Failed to vote on comment: $e');
355 }
356 rethrow;
357 }
358 }
359
360 /// Create a comment on the current post or as a reply to another comment
361 ///
362 /// Parameters:
363 /// - [content]: The comment text content
364 /// - [parentComment]: Optional parent comment for nested replies.
365 /// If null, this is a top-level reply to the post.
366 ///
367 /// The reply reference structure:
368 /// - Root: Always points to the original post (_postUri, _postCid)
369 /// - Parent: Points to the post (top-level) or the parent comment (nested)
370 ///
371 /// After successful creation, refreshes the comments list.
372 ///
373 /// Throws:
374 /// - ValidationException if content is empty or too long
375 /// - ApiException if CommentService is not available or no post is loaded
376 /// - ApiException for API errors
377 Future<void> createComment({
378 required String content,
379 ThreadViewComment? parentComment,
380 }) async {
381 // Validate content
382 final trimmedContent = content.trim();
383 if (trimmedContent.isEmpty) {
384 throw ValidationException('Comment cannot be empty');
385 }
386
387 // Use characters.length for proper Unicode/emoji counting
388 final charCount = trimmedContent.characters.length;
389 if (charCount > maxCommentLength) {
390 throw ValidationException(
391 'Comment too long ($charCount characters). '
392 'Maximum is $maxCommentLength characters.',
393 );
394 }
395
396 if (_commentService == null) {
397 throw ApiException('CommentService not available');
398 }
399
400 if (_postUri == null || _postCid == null) {
401 throw ApiException('No post loaded - cannot create comment');
402 }
403
404 // Root is always the original post
405 final rootUri = _postUri!;
406 final rootCid = _postCid!;
407
408 // Parent depends on whether this is a top-level or nested reply
409 final String parentUri;
410 final String parentCid;
411
412 if (parentComment != null) {
413 // Nested reply - parent is the comment being replied to
414 parentUri = parentComment.comment.uri;
415 parentCid = parentComment.comment.cid;
416 } else {
417 // Top-level reply - parent is the post
418 parentUri = rootUri;
419 parentCid = rootCid;
420 }
421
422 if (kDebugMode) {
423 debugPrint('💬 Creating comment');
424 debugPrint(' Root: $rootUri');
425 debugPrint(' Parent: $parentUri');
426 debugPrint(' Is nested: ${parentComment != null}');
427 }
428
429 try {
430 final response = await _commentService.createComment(
431 rootUri: rootUri,
432 rootCid: rootCid,
433 parentUri: parentUri,
434 parentCid: parentCid,
435 content: trimmedContent,
436 );
437
438 if (kDebugMode) {
439 debugPrint('✅ Comment created: ${response.uri}');
440 }
441
442 // Refresh comments to show the new comment
443 await refreshComments();
444 } on Exception catch (e) {
445 if (kDebugMode) {
446 debugPrint('❌ Failed to create comment: $e');
447 }
448 rethrow;
449 }
450 }
451
452 /// Initialize vote state for a comment and its replies recursively
453 ///
454 /// Extracts viewer vote data from comment and initializes VoteProvider state.
455 /// Handles nested replies recursively.
456 ///
457 /// IMPORTANT: Always calls setInitialVoteState, even when viewer.vote is
458 /// null. This ensures that if a user removed their vote on another device,
459 /// the local state is cleared on refresh.
460 void _initializeCommentVoteState(ThreadViewComment threadComment) {
461 final viewer = threadComment.comment.viewer;
462 _voteProvider!.setInitialVoteState(
463 postUri: threadComment.comment.uri,
464 voteDirection: viewer?.vote,
465 voteUri: viewer?.voteUri,
466 );
467
468 // Recursively initialize vote state for replies
469 threadComment.replies?.forEach(_initializeCommentVoteState);
470 }
471
472 /// Retry loading after error
473 Future<void> retry() async {
474 _error = null;
475 if (_postUri != null && _postCid != null) {
476 await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
477 }
478 }
479
480 /// Clear error
481 void clearError() {
482 _error = null;
483 notifyListeners();
484 }
485
486 /// Reset comment state
487 void reset() {
488 _comments = [];
489 _cursor = null;
490 _hasMore = true;
491 _error = null;
492 _isLoading = false;
493 _isLoadingMore = false;
494 _postUri = null;
495 _postCid = null;
496 _pendingRefresh = false;
497 notifyListeners();
498 }
499
500 @override
501 void dispose() {
502 // Stop time updates and cancel timer (also sets value to null)
503 stopTimeUpdates();
504 // Remove auth listener to prevent memory leaks
505 _authProvider.removeListener(_onAuthChanged);
506 _apiService.dispose();
507 // Dispose the ValueNotifier last
508 _currentTimeNotifier.dispose();
509 super.dispose();
510 }
511}