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}