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
···
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/comment_service.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
signOutHandler: authProvider.signOut,
);
+
// Initialize comment service with auth callbacks
+
// Comments go through the Coves backend (which proxies to PDS with DPoP)
+
final commentService = CommentService(
+
sessionGetter: () async => authProvider.session,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
runApp(
MultiProvider(
providers: [
···
(context) => CommentsProvider(
authProvider,
voteProvider: context.read<VoteProvider>(),
+
commentService: commentService,
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
···
CommentsProvider(
auth,
voteProvider: vote,
+
commentService: commentService,
);
},
),
+128 -11
lib/providers/comments_provider.dart
···
import 'dart:async' show Timer, unawaited;
+
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import '../models/comment.dart';
+
import '../services/api_exceptions.dart';
+
import '../services/comment_service.dart';
import '../services/coves_api_service.dart';
import 'auth_provider.dart';
import 'vote_provider.dart';
···
this._authProvider, {
CovesApiService? apiService,
VoteProvider? voteProvider,
-
}) : _voteProvider = voteProvider {
+
CommentService? commentService,
+
}) : _voteProvider = voteProvider,
+
_commentService = commentService {
// Use injected service (for testing) or create new one (for production)
// Pass token getter, refresh handler, and sign out handler to API service
// for automatic fresh token retrieval and automatic token refresh on 401
···
_authProvider.addListener(_onAuthChanged);
}
+
/// Maximum comment length in characters (matches backend limit)
+
/// Note: This counts Unicode grapheme clusters, so emojis count correctly
+
static const int maxCommentLength = 10000;
+
/// Handle authentication state changes
///
/// Clears comment state when user signs out to prevent privacy issues.
···
final AuthProvider _authProvider;
late final CovesApiService _apiService;
final VoteProvider? _voteProvider;
+
final CommentService? _commentService;
// Track previous auth state to detect transitions
bool _wasAuthenticated = false;
···
String? _cursor;
bool _hasMore = true;
-
// Current post URI being viewed
+
// Current post being viewed
String? _postUri;
+
String? _postCid;
// Comment configuration
String _sort = 'hot';
···
}
/// Load comments for a specific post
+
///
+
/// Parameters:
+
/// - [postUri]: AT-URI of the post
+
/// - [postCid]: CID of the post (needed for creating comments)
+
/// - [refresh]: Whether to refresh from the beginning
Future<void> loadComments({
required String postUri,
+
required String postCid,
bool refresh = false,
}) async {
// If loading for a different post, reset state
if (postUri != _postUri) {
reset();
_postUri = postUri;
+
_postCid = postCid;
}
// If already loading, schedule a refresh to happen after current load
···
_pendingRefresh = false;
// Schedule refresh without awaiting to avoid blocking
// This is intentional - we want the refresh to happen asynchronously
-
unawaited(loadComments(postUri: _postUri!, refresh: true));
+
unawaited(
+
loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true),
+
);
}
}
}
···
///
/// Reloads comments from the beginning for the current post.
Future<void> refreshComments() async {
-
if (_postUri == null) {
+
if (_postUri == null || _postCid == null) {
if (kDebugMode) {
debugPrint('⚠️ Cannot refresh - no post loaded');
}
return;
}
-
await loadComments(postUri: _postUri!, refresh: true);
+
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
}
/// Load more comments (pagination)
Future<void> loadMoreComments() async {
-
if (!_hasMore || _isLoadingMore || _postUri == null) {
+
if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) {
return;
}
-
await loadComments(postUri: _postUri!);
+
await loadComments(postUri: _postUri!, postCid: _postCid!);
}
/// Change sort order
···
notifyListeners();
// Reload comments with new sort
-
if (_postUri != null) {
+
if (_postUri != null && _postCid != null) {
try {
-
await loadComments(postUri: _postUri!, refresh: true);
+
await loadComments(
+
postUri: _postUri!,
+
postCid: _postCid!,
+
refresh: true,
+
);
return true;
} on Exception catch (e) {
// Revert to previous sort option on failure
···
}
}
+
/// Create a comment on the current post or as a reply to another comment
+
///
+
/// Parameters:
+
/// - [content]: The comment text content
+
/// - [parentComment]: Optional parent comment for nested replies.
+
/// If null, this is a top-level reply to the post.
+
///
+
/// The reply reference structure:
+
/// - Root: Always points to the original post (_postUri, _postCid)
+
/// - Parent: Points to the post (top-level) or the parent comment (nested)
+
///
+
/// After successful creation, refreshes the comments list.
+
///
+
/// Throws:
+
/// - ValidationException if content is empty or too long
+
/// - ApiException if CommentService is not available or no post is loaded
+
/// - ApiException for API errors
+
Future<void> createComment({
+
required String content,
+
ThreadViewComment? parentComment,
+
}) async {
+
// Validate content
+
final trimmedContent = content.trim();
+
if (trimmedContent.isEmpty) {
+
throw ValidationException('Comment cannot be empty');
+
}
+
+
// Use characters.length for proper Unicode/emoji counting
+
final charCount = trimmedContent.characters.length;
+
if (charCount > maxCommentLength) {
+
throw ValidationException(
+
'Comment too long ($charCount characters). '
+
'Maximum is $maxCommentLength characters.',
+
);
+
}
+
+
if (_commentService == null) {
+
throw ApiException('CommentService not available');
+
}
+
+
if (_postUri == null || _postCid == null) {
+
throw ApiException('No post loaded - cannot create comment');
+
}
+
+
// Root is always the original post
+
final rootUri = _postUri!;
+
final rootCid = _postCid!;
+
+
// Parent depends on whether this is a top-level or nested reply
+
final String parentUri;
+
final String parentCid;
+
+
if (parentComment != null) {
+
// Nested reply - parent is the comment being replied to
+
parentUri = parentComment.comment.uri;
+
parentCid = parentComment.comment.cid;
+
} else {
+
// Top-level reply - parent is the post
+
parentUri = rootUri;
+
parentCid = rootCid;
+
}
+
+
if (kDebugMode) {
+
debugPrint('💬 Creating comment');
+
debugPrint(' Root: $rootUri');
+
debugPrint(' Parent: $parentUri');
+
debugPrint(' Is nested: ${parentComment != null}');
+
}
+
+
try {
+
final response = await _commentService.createComment(
+
rootUri: rootUri,
+
rootCid: rootCid,
+
parentUri: parentUri,
+
parentCid: parentCid,
+
content: trimmedContent,
+
);
+
+
if (kDebugMode) {
+
debugPrint('✅ Comment created: ${response.uri}');
+
}
+
+
// Refresh comments to show the new comment
+
await refreshComments();
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ Failed to create comment: $e');
+
}
+
rethrow;
+
}
+
}
+
/// Initialize vote state for a comment and its replies recursively
///
/// Extracts viewer vote data from comment and initializes VoteProvider state.
···
/// Retry loading after error
Future<void> retry() async {
_error = null;
-
if (_postUri != null) {
-
await loadComments(postUri: _postUri!, refresh: true);
+
if (_postUri != null && _postCid != null) {
+
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
}
}
···
_isLoading = false;
_isLoadingMore = false;
_postUri = null;
+
_postCid = null;
_pendingRefresh = false;
notifyListeners();
}