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