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}