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}