feat(comments): add createComment to CommentsProvider with validation

- Add CommentService dependency to CommentsProvider
- Add createComment() method supporting both post and comment replies
- Store postCid alongside postUri for proper reply references
- Add input validation: 10k char limit using grapheme clusters
- Proper emoji counting (🎉 = 1 char, not 2)
- Wire up CommentService in main.dart

Reply reference logic:
- Reply to post: root=post, parent=post
- Reply to comment: root=post, parent=comment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+139 -11
lib
+11
lib/main.dart
··· 15 15 import 'screens/home/main_shell_screen.dart'; 16 16 import 'screens/home/post_detail_screen.dart'; 17 17 import 'screens/landing_screen.dart'; 18 + import 'services/comment_service.dart'; 18 19 import 'services/streamable_service.dart'; 19 20 import 'services/vote_service.dart'; 20 21 import 'widgets/loading_error_states.dart'; ··· 44 45 signOutHandler: authProvider.signOut, 45 46 ); 46 47 48 + // Initialize comment service with auth callbacks 49 + // Comments go through the Coves backend (which proxies to PDS with DPoP) 50 + final commentService = CommentService( 51 + sessionGetter: () async => authProvider.session, 52 + tokenRefresher: authProvider.refreshToken, 53 + signOutHandler: authProvider.signOut, 54 + ); 55 + 47 56 runApp( 48 57 MultiProvider( 49 58 providers: [ ··· 79 88 (context) => CommentsProvider( 80 89 authProvider, 81 90 voteProvider: context.read<VoteProvider>(), 91 + commentService: commentService, 82 92 ), 83 93 update: (context, auth, vote, previous) { 84 94 // Reuse existing provider to maintain state across rebuilds ··· 86 96 CommentsProvider( 87 97 auth, 88 98 voteProvider: vote, 99 + commentService: commentService, 89 100 ); 90 101 }, 91 102 ),
+128 -11
lib/providers/comments_provider.dart
··· 1 1 import 'dart:async' show Timer, unawaited; 2 2 3 + import 'package:characters/characters.dart'; 3 4 import 'package:flutter/foundation.dart'; 4 5 import '../models/comment.dart'; 6 + import '../services/api_exceptions.dart'; 7 + import '../services/comment_service.dart'; 5 8 import '../services/coves_api_service.dart'; 6 9 import 'auth_provider.dart'; 7 10 import 'vote_provider.dart'; ··· 19 22 this._authProvider, { 20 23 CovesApiService? apiService, 21 24 VoteProvider? voteProvider, 22 - }) : _voteProvider = voteProvider { 25 + CommentService? commentService, 26 + }) : _voteProvider = voteProvider, 27 + _commentService = commentService { 23 28 // Use injected service (for testing) or create new one (for production) 24 29 // Pass token getter, refresh handler, and sign out handler to API service 25 30 // for automatic fresh token retrieval and automatic token refresh on 401 ··· 38 43 _authProvider.addListener(_onAuthChanged); 39 44 } 40 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 + 41 50 /// Handle authentication state changes 42 51 /// 43 52 /// Clears comment state when user signs out to prevent privacy issues. ··· 59 68 final AuthProvider _authProvider; 60 69 late final CovesApiService _apiService; 61 70 final VoteProvider? _voteProvider; 71 + final CommentService? _commentService; 62 72 63 73 // Track previous auth state to detect transitions 64 74 bool _wasAuthenticated = false; ··· 71 81 String? _cursor; 72 82 bool _hasMore = true; 73 83 74 - // Current post URI being viewed 84 + // Current post being viewed 75 85 String? _postUri; 86 + String? _postCid; 76 87 77 88 // Comment configuration 78 89 String _sort = 'hot'; ··· 131 142 } 132 143 133 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 134 150 Future<void> loadComments({ 135 151 required String postUri, 152 + required String postCid, 136 153 bool refresh = false, 137 154 }) async { 138 155 // If loading for a different post, reset state 139 156 if (postUri != _postUri) { 140 157 reset(); 141 158 _postUri = postUri; 159 + _postCid = postCid; 142 160 } 143 161 144 162 // If already loading, schedule a refresh to happen after current load ··· 225 243 _pendingRefresh = false; 226 244 // Schedule refresh without awaiting to avoid blocking 227 245 // This is intentional - we want the refresh to happen asynchronously 228 - unawaited(loadComments(postUri: _postUri!, refresh: true)); 246 + unawaited( 247 + loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true), 248 + ); 229 249 } 230 250 } 231 251 } ··· 234 254 /// 235 255 /// Reloads comments from the beginning for the current post. 236 256 Future<void> refreshComments() async { 237 - if (_postUri == null) { 257 + if (_postUri == null || _postCid == null) { 238 258 if (kDebugMode) { 239 259 debugPrint('⚠️ Cannot refresh - no post loaded'); 240 260 } 241 261 return; 242 262 } 243 - await loadComments(postUri: _postUri!, refresh: true); 263 + await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true); 244 264 } 245 265 246 266 /// Load more comments (pagination) 247 267 Future<void> loadMoreComments() async { 248 - if (!_hasMore || _isLoadingMore || _postUri == null) { 268 + if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) { 249 269 return; 250 270 } 251 - await loadComments(postUri: _postUri!); 271 + await loadComments(postUri: _postUri!, postCid: _postCid!); 252 272 } 253 273 254 274 /// Change sort order ··· 268 288 notifyListeners(); 269 289 270 290 // Reload comments with new sort 271 - if (_postUri != null) { 291 + if (_postUri != null && _postCid != null) { 272 292 try { 273 - await loadComments(postUri: _postUri!, refresh: true); 293 + await loadComments( 294 + postUri: _postUri!, 295 + postCid: _postCid!, 296 + refresh: true, 297 + ); 274 298 return true; 275 299 } on Exception catch (e) { 276 300 // Revert to previous sort option on failure ··· 333 357 } 334 358 } 335 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 + 336 452 /// Initialize vote state for a comment and its replies recursively 337 453 /// 338 454 /// Extracts viewer vote data from comment and initializes VoteProvider state. ··· 356 472 /// Retry loading after error 357 473 Future<void> retry() async { 358 474 _error = null; 359 - if (_postUri != null) { 360 - await loadComments(postUri: _postUri!, refresh: true); 475 + if (_postUri != null && _postCid != null) { 476 + await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true); 361 477 } 362 478 } 363 479 ··· 376 492 _isLoading = false; 377 493 _isLoadingMore = false; 378 494 _postUri = null; 495 + _postCid = null; 379 496 _pendingRefresh = false; 380 497 notifyListeners(); 381 498 }