Main coves client
1import 'package:dio/dio.dart';
2import 'package:flutter/foundation.dart';
3
4import '../config/environment_config.dart';
5import '../models/coves_session.dart';
6import 'api_exceptions.dart';
7import 'auth_interceptor.dart';
8
9/// Comment Service
10///
11/// Handles comment creation through the Coves backend.
12///
13/// **Architecture with Backend OAuth**:
14/// With sealed tokens, the client cannot write directly to the user's PDS
15/// (no DPoP keys available). Instead, comments go through the Coves backend:
16///
17/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
18///
19/// The backend:
20/// 1. Unseals the token to get the actual access/refresh tokens
21/// 2. Uses stored DPoP keys to sign requests
22/// 3. Writes to the user's PDS on their behalf
23///
24/// **Backend Endpoint**:
25/// - POST /xrpc/social.coves.community.comment.create
26class CommentService {
27 CommentService({
28 Future<CovesSession?> Function()? sessionGetter,
29 Future<bool> Function()? tokenRefresher,
30 Future<void> Function()? signOutHandler,
31 Dio? dio,
32 }) : _sessionGetter = sessionGetter {
33 _dio =
34 dio ??
35 Dio(
36 BaseOptions(
37 baseUrl: EnvironmentConfig.current.apiUrl,
38 connectTimeout: const Duration(seconds: 30),
39 receiveTimeout: const Duration(seconds: 30),
40 headers: {'Content-Type': 'application/json'},
41 ),
42 );
43
44 // Add shared 401 retry interceptor
45 _dio.interceptors.add(
46 createAuthInterceptor(
47 sessionGetter: sessionGetter,
48 tokenRefresher: tokenRefresher,
49 signOutHandler: signOutHandler,
50 serviceName: 'CommentService',
51 dio: _dio,
52 ),
53 );
54 }
55
56 final Future<CovesSession?> Function()? _sessionGetter;
57 late final Dio _dio;
58
59 /// Create a comment
60 ///
61 /// Sends comment request to the Coves backend, which writes to the
62 /// user's PDS.
63 ///
64 /// Parameters:
65 /// - [rootUri]: AT-URI of the root post (always the original post)
66 /// - [rootCid]: CID of the root post
67 /// - [parentUri]: AT-URI of the parent (post or comment)
68 /// - [parentCid]: CID of the parent
69 /// - [content]: Comment text content
70 ///
71 /// Returns:
72 /// - CreateCommentResponse with uri and cid of the created comment
73 ///
74 /// Throws:
75 /// - ApiException for API errors
76 /// - AuthenticationException for auth failures
77 Future<CreateCommentResponse> createComment({
78 required String rootUri,
79 required String rootCid,
80 required String parentUri,
81 required String parentCid,
82 required String content,
83 }) async {
84 try {
85 final session = await _sessionGetter?.call();
86
87 if (session == null) {
88 throw AuthenticationException(
89 'User not authenticated - no session available',
90 );
91 }
92
93 if (kDebugMode) {
94 debugPrint('💬 Creating comment via backend');
95 debugPrint(' Root: $rootUri');
96 debugPrint(' Parent: $parentUri');
97 debugPrint(' Content length: ${content.length}');
98 }
99
100 // Send comment request to backend
101 // Note: Authorization header is added by the interceptor
102 final response = await _dio.post<Map<String, dynamic>>(
103 '/xrpc/social.coves.community.comment.create',
104 data: {
105 'reply': {
106 'root': {'uri': rootUri, 'cid': rootCid},
107 'parent': {'uri': parentUri, 'cid': parentCid},
108 },
109 'content': content,
110 },
111 );
112
113 final data = response.data;
114 if (data == null) {
115 throw ApiException('Invalid response from server - no data');
116 }
117
118 final uri = data['uri'] as String?;
119 final cid = data['cid'] as String?;
120
121 if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
122 throw ApiException('Invalid response from server - missing uri or cid');
123 }
124
125 if (kDebugMode) {
126 debugPrint('✅ Comment created: $uri');
127 }
128
129 return CreateCommentResponse(uri: uri, cid: cid);
130 } on DioException catch (e) {
131 if (kDebugMode) {
132 debugPrint('❌ Comment creation failed: ${e.message}');
133 debugPrint(' Status: ${e.response?.statusCode}');
134 debugPrint(' Data: ${e.response?.data}');
135 }
136
137 if (e.response?.statusCode == 401) {
138 throw AuthenticationException(
139 'Authentication failed. Please sign in again.',
140 originalError: e,
141 );
142 }
143
144 throw ApiException(
145 'Failed to create comment: ${e.message}',
146 statusCode: e.response?.statusCode,
147 originalError: e,
148 );
149 } on AuthenticationException {
150 rethrow;
151 } on ApiException {
152 rethrow;
153 } on Exception catch (e) {
154 throw ApiException('Failed to create comment: $e');
155 }
156 }
157}
158
159/// Response from comment creation
160class CreateCommentResponse {
161 const CreateCommentResponse({required this.uri, required this.cid});
162
163 /// AT-URI of the created comment record
164 final String uri;
165
166 /// CID of the created comment record
167 final String cid;
168}