Main coves client
1import 'dart:async' show Timer, unawaited;
2
3import 'package:flutter/foundation.dart';
4import '../models/comment.dart';
5import '../services/coves_api_service.dart';
6import 'auth_provider.dart';
7import 'vote_provider.dart';
8
9/// Comments Provider
10///
11/// Manages comment state and fetching logic for a specific post.
12/// Supports sorting (hot/top/new), pagination, and vote integration.
13///
14/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
15/// tokens before each authenticated request (critical for atProto OAuth
16/// token rotation).
17class CommentsProvider with ChangeNotifier {
18 CommentsProvider(
19 this._authProvider, {
20 CovesApiService? apiService,
21 VoteProvider? voteProvider,
22 }) : _voteProvider = voteProvider {
23 // Use injected service (for testing) or create new one (for production)
24 // Pass token getter, refresh handler, and sign out handler to API service
25 // for automatic fresh token retrieval and automatic token refresh on 401
26 _apiService =
27 apiService ??
28 CovesApiService(
29 tokenGetter: _authProvider.getAccessToken,
30 tokenRefresher: _authProvider.refreshToken,
31 signOutHandler: _authProvider.signOut,
32 );
33
34 // Track initial auth state
35 _wasAuthenticated = _authProvider.isAuthenticated;
36
37 // Listen to auth state changes and clear comments on sign-out
38 _authProvider.addListener(_onAuthChanged);
39 }
40
41 /// Handle authentication state changes
42 ///
43 /// Clears comment state when user signs out to prevent privacy issues.
44 void _onAuthChanged() {
45 final isAuthenticated = _authProvider.isAuthenticated;
46
47 // Only clear if transitioning from authenticated → unauthenticated
48 if (_wasAuthenticated && !isAuthenticated && _comments.isNotEmpty) {
49 if (kDebugMode) {
50 debugPrint('🔒 User signed out - clearing comments');
51 }
52 reset();
53 }
54
55 // Update tracked state
56 _wasAuthenticated = isAuthenticated;
57 }
58
59 final AuthProvider _authProvider;
60 late final CovesApiService _apiService;
61 final VoteProvider? _voteProvider;
62
63 // Track previous auth state to detect transitions
64 bool _wasAuthenticated = false;
65
66 // Comment state
67 List<ThreadViewComment> _comments = [];
68 bool _isLoading = false;
69 bool _isLoadingMore = false;
70 String? _error;
71 String? _cursor;
72 bool _hasMore = true;
73
74 // Current post URI being viewed
75 String? _postUri;
76
77 // Comment configuration
78 String _sort = 'hot';
79 String? _timeframe;
80
81 // Flag to track if a refresh should be scheduled after current load
82 bool _pendingRefresh = false;
83
84 // Time update mechanism for periodic UI refreshes
85 Timer? _timeUpdateTimer;
86 final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null);
87
88 // Getters
89 List<ThreadViewComment> get comments => _comments;
90 bool get isLoading => _isLoading;
91 bool get isLoadingMore => _isLoadingMore;
92 String? get error => _error;
93 bool get hasMore => _hasMore;
94 String get sort => _sort;
95 String? get timeframe => _timeframe;
96 ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
97
98 /// Start periodic time updates for "time ago" strings
99 ///
100 /// Updates currentTime every minute to trigger UI rebuilds for
101 /// comment timestamps. This ensures "5m ago" updates to "6m ago" without
102 /// requiring user interaction.
103 ///
104 /// Uses ValueNotifier to avoid triggering full provider rebuilds.
105 void startTimeUpdates() {
106 // Cancel existing timer if any
107 _timeUpdateTimer?.cancel();
108
109 // Update current time immediately
110 _currentTimeNotifier.value = DateTime.now();
111
112 // Set up periodic updates (every minute)
113 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
114 _currentTimeNotifier.value = DateTime.now();
115 });
116
117 if (kDebugMode) {
118 debugPrint('⏰ Started periodic time updates for comment timestamps');
119 }
120 }
121
122 /// Stop periodic time updates
123 void stopTimeUpdates() {
124 _timeUpdateTimer?.cancel();
125 _timeUpdateTimer = null;
126 _currentTimeNotifier.value = null;
127
128 if (kDebugMode) {
129 debugPrint('⏰ Stopped periodic time updates');
130 }
131 }
132
133 /// Load comments for a specific post
134 Future<void> loadComments({
135 required String postUri,
136 bool refresh = false,
137 }) async {
138 // If loading for a different post, reset state
139 if (postUri != _postUri) {
140 reset();
141 _postUri = postUri;
142 }
143
144 // If already loading, schedule a refresh to happen after current load
145 if (_isLoading || _isLoadingMore) {
146 if (refresh) {
147 _pendingRefresh = true;
148 if (kDebugMode) {
149 debugPrint(
150 '⏳ Load in progress - scheduled refresh for after completion',
151 );
152 }
153 }
154 return;
155 }
156
157 try {
158 if (refresh) {
159 _isLoading = true;
160 _error = null;
161 _pendingRefresh = false; // Clear any pending refresh
162 } else {
163 _isLoadingMore = true;
164 }
165 notifyListeners();
166
167 if (kDebugMode) {
168 debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri');
169 }
170
171 final response = await _apiService.getComments(
172 postUri: postUri,
173 sort: _sort,
174 timeframe: _timeframe,
175 cursor: refresh ? null : _cursor,
176 );
177
178 // Only update state after successful fetch
179 if (refresh) {
180 _comments = response.comments;
181 } else {
182 // Create new list instance to trigger rebuilds
183 _comments = [..._comments, ...response.comments];
184 }
185
186 _cursor = response.cursor;
187 _hasMore = response.cursor != null;
188 _error = null;
189
190 if (kDebugMode) {
191 debugPrint('✅ Comments loaded: ${_comments.length} comments total');
192 }
193
194 // Initialize vote state from viewer data in comments response
195 if (_authProvider.isAuthenticated && _voteProvider != null) {
196 if (refresh) {
197 // On refresh, initialize all comments - server data is truth
198 _comments.forEach(_initializeCommentVoteState);
199 } else {
200 // On pagination, only initialize newly fetched comments to avoid
201 // overwriting optimistic vote state on existing comments
202 response.comments.forEach(_initializeCommentVoteState);
203 }
204 }
205
206 // Start time updates when comments are loaded
207 if (_comments.isNotEmpty && _timeUpdateTimer == null) {
208 startTimeUpdates();
209 }
210 } on Exception catch (e) {
211 _error = e.toString();
212 if (kDebugMode) {
213 debugPrint('❌ Failed to fetch comments: $e');
214 }
215 } finally {
216 _isLoading = false;
217 _isLoadingMore = false;
218 notifyListeners();
219
220 // If a refresh was scheduled during this load, execute it now
221 if (_pendingRefresh && _postUri != null) {
222 if (kDebugMode) {
223 debugPrint('🔄 Executing pending refresh');
224 }
225 _pendingRefresh = false;
226 // Schedule refresh without awaiting to avoid blocking
227 // This is intentional - we want the refresh to happen asynchronously
228 unawaited(loadComments(postUri: _postUri!, refresh: true));
229 }
230 }
231 }
232
233 /// Refresh comments (pull-to-refresh)
234 ///
235 /// Reloads comments from the beginning for the current post.
236 Future<void> refreshComments() async {
237 if (_postUri == null) {
238 if (kDebugMode) {
239 debugPrint('⚠️ Cannot refresh - no post loaded');
240 }
241 return;
242 }
243 await loadComments(postUri: _postUri!, refresh: true);
244 }
245
246 /// Load more comments (pagination)
247 Future<void> loadMoreComments() async {
248 if (!_hasMore || _isLoadingMore || _postUri == null) {
249 return;
250 }
251 await loadComments(postUri: _postUri!);
252 }
253
254 /// Change sort order
255 ///
256 /// Updates the sort option and triggers a refresh of comments.
257 /// Available options: 'hot', 'top', 'new'
258 ///
259 /// Returns true if sort change succeeded, false if reload failed.
260 /// On failure, reverts to previous sort option.
261 Future<bool> setSortOption(String newSort) async {
262 if (_sort == newSort) {
263 return true;
264 }
265
266 final previousSort = _sort;
267 _sort = newSort;
268 notifyListeners();
269
270 // Reload comments with new sort
271 if (_postUri != null) {
272 try {
273 await loadComments(postUri: _postUri!, refresh: true);
274 return true;
275 } on Exception catch (e) {
276 // Revert to previous sort option on failure
277 _sort = previousSort;
278 notifyListeners();
279
280 if (kDebugMode) {
281 debugPrint('Failed to apply sort option: $e');
282 }
283
284 return false;
285 }
286 }
287
288 return true;
289 }
290
291 /// Vote on a comment
292 ///
293 /// Delegates to VoteProvider for optimistic updates and API calls.
294 /// The VoteProvider handles:
295 /// - Optimistic UI updates
296 /// - API call to user's PDS
297 /// - Rollback on error
298 ///
299 /// Parameters:
300 /// - [commentUri]: AT-URI of the comment
301 /// - [commentCid]: Content ID of the comment
302 /// - [voteType]: Vote direction ('up' or 'down')
303 ///
304 /// Returns:
305 /// - true if vote was created
306 /// - false if vote was removed (toggled off)
307 Future<bool> voteOnComment({
308 required String commentUri,
309 required String commentCid,
310 String voteType = 'up',
311 }) async {
312 if (_voteProvider == null) {
313 throw Exception('VoteProvider not available');
314 }
315
316 try {
317 final result = await _voteProvider.toggleVote(
318 postUri: commentUri,
319 postCid: commentCid,
320 direction: voteType,
321 );
322
323 if (kDebugMode) {
324 debugPrint('✅ Comment vote ${result ? 'created' : 'removed'}');
325 }
326
327 return result;
328 } on Exception catch (e) {
329 if (kDebugMode) {
330 debugPrint('❌ Failed to vote on comment: $e');
331 }
332 rethrow;
333 }
334 }
335
336 /// Initialize vote state for a comment and its replies recursively
337 ///
338 /// Extracts viewer vote data from comment and initializes VoteProvider state.
339 /// Handles nested replies recursively.
340 ///
341 /// IMPORTANT: Always calls setInitialVoteState, even when viewer.vote is
342 /// null. This ensures that if a user removed their vote on another device,
343 /// the local state is cleared on refresh.
344 void _initializeCommentVoteState(ThreadViewComment threadComment) {
345 final viewer = threadComment.comment.viewer;
346 _voteProvider!.setInitialVoteState(
347 postUri: threadComment.comment.uri,
348 voteDirection: viewer?.vote,
349 voteUri: viewer?.voteUri,
350 );
351
352 // Recursively initialize vote state for replies
353 threadComment.replies?.forEach(_initializeCommentVoteState);
354 }
355
356 /// Retry loading after error
357 Future<void> retry() async {
358 _error = null;
359 if (_postUri != null) {
360 await loadComments(postUri: _postUri!, refresh: true);
361 }
362 }
363
364 /// Clear error
365 void clearError() {
366 _error = null;
367 notifyListeners();
368 }
369
370 /// Reset comment state
371 void reset() {
372 _comments = [];
373 _cursor = null;
374 _hasMore = true;
375 _error = null;
376 _isLoading = false;
377 _isLoadingMore = false;
378 _postUri = null;
379 _pendingRefresh = false;
380 notifyListeners();
381 }
382
383 @override
384 void dispose() {
385 // Stop time updates and cancel timer (also sets value to null)
386 stopTimeUpdates();
387 // Remove auth listener to prevent memory leaks
388 _authProvider.removeListener(_onAuthChanged);
389 _apiService.dispose();
390 // Dispose the ValueNotifier last
391 _currentTimeNotifier.dispose();
392 super.dispose();
393 }
394}