[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: standalone post page for replies

fixes #109 because it supports nested replies

+323 -155
+3 -1
lib/src/core/notifications/push_notification_service.dart
··· 103 103 if (reason == 'follow' && author != null) { 104 104 // Navigate to profile for follow notifications 105 105 router.push(ProfileRoute(did: author)); 106 + } else if (reason == 'reply' && recordUri != null) { 107 + router.push(StandalonePostRoute(postUri: recordUri)); 106 108 } else if (reasonSubject != null) { 107 109 // For likes/reposts, navigate to the subject (the post being liked/reposted) 108 110 router.push(StandalonePostRoute(postUri: reasonSubject)); 109 111 } else if (recordUri != null) { 110 - // For replies/mentions, navigate to the record itself 112 + // For mentions and other record notifications, navigate to the record itself 111 113 router.push(StandalonePostRoute(postUri: recordUri)); 112 114 } else if (author != null) { 113 115 // Fallback to author profile
+42 -1
lib/src/features/comments/ui/pages/replies_page.dart
··· 11 11 12 12 @RoutePage() 13 13 class RepliesPage extends ConsumerStatefulWidget { 14 - const RepliesPage({required this.postUri, super.key}); 14 + const RepliesPage({ 15 + required this.postUri, 16 + super.key, 17 + this.highlightedReplyUri, 18 + }); 15 19 final String postUri; 20 + final String? highlightedReplyUri; 16 21 17 22 @override 18 23 ConsumerState<RepliesPage> createState() => _RepliesPageState(); ··· 21 26 class _RepliesPageState extends ConsumerState<RepliesPage> { 22 27 final _scrollController = ScrollController(); 23 28 final _focusNode = FocusNode(); 29 + bool _hasScrolledToHighlighted = false; 24 30 25 31 @override 26 32 void initState() { ··· 58 64 } 59 65 } 60 66 67 + void _scrollToHighlightedReply(List<dynamic> replies) { 68 + if (_hasScrolledToHighlighted || widget.highlightedReplyUri == null) return; 69 + _hasScrolledToHighlighted = true; 70 + 71 + int? highlightedIndex; 72 + for (var i = 0; i < replies.length; i++) { 73 + final reply = replies[i] as ThreadViewPost; 74 + if (reply.post.uri.toString() == widget.highlightedReplyUri) { 75 + highlightedIndex = i; 76 + break; 77 + } 78 + } 79 + 80 + if (highlightedIndex != null && _scrollController.hasClients) { 81 + final estimatedOffset = highlightedIndex * 100.0; 82 + _scrollController.animateTo( 83 + estimatedOffset.clamp(0, _scrollController.position.maxScrollExtent), 84 + duration: const Duration(milliseconds: 400), 85 + curve: Curves.easeOut, 86 + ); 87 + } 88 + } 89 + 61 90 /// Extracts the thread root URI and CID from the reply record. 62 91 /// Returns null if the thread post is a root post (not a reply). 63 92 ({String uri, String cid})? _getThreadRoot(ThreadViewPost thread) { ··· 105 134 body: state.when( 106 135 data: (data) { 107 136 final threadRoot = _getThreadRoot(data.thread); 137 + if (widget.highlightedReplyUri != null && 138 + !_hasScrolledToHighlighted) { 139 + WidgetsBinding.instance.addPostFrameCallback((_) { 140 + _scrollToHighlightedReply(data.thread.replies ?? const []); 141 + }); 142 + } 143 + 108 144 return SafeArea( 109 145 child: Column( 110 146 children: [ ··· 127 163 itemBuilder: (context, index) { 128 164 final comment = 129 165 data.thread.replies![index] as ThreadViewPost; 166 + final isHighlighted = 167 + widget.highlightedReplyUri != null && 168 + comment.post.uri.toString() == 169 + widget.highlightedReplyUri; 130 170 return CommentItem( 131 171 key: ValueKey('comment-${comment.post.cid}'), 132 172 thread: comment, 133 173 mainPostUri: AtUri.parse(widget.postUri), 174 + isHighlighted: isHighlighted, 134 175 ); 135 176 }, 136 177 ),
+277 -48
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 22 22 23 23 @RoutePage() 24 24 class StandalonePostPage extends ConsumerStatefulWidget { 25 - const StandalonePostPage({ 26 - required this.postUri, 27 - super.key, 28 - this.highlightedReplyUri, 29 - }); 25 + const StandalonePostPage({required this.postUri, super.key}); 30 26 31 27 final String postUri; 32 28 33 - /// If provided, automatically opens comments modal with reply highlighted 34 - final String? highlightedReplyUri; 35 - 36 29 @override 37 30 ConsumerState<StandalonePostPage> createState() => _StandalonePostPageState(); 38 31 } 39 32 40 33 class _StandalonePostPageState extends ConsumerState<StandalonePostPage> { 41 - Future<PostView>? _postFuture; 42 - int? _lastUpdateCount; 34 + Future<_ResolvedStandalonePost>? _postFuture; 43 35 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = 44 36 GlobalKey<PostVideoPlayerState>(); 45 37 bool _showWarningOverlay = false; 46 38 List<String> _warningLabels = []; 47 39 bool _shouldBlurContent = false; 48 40 bool _hasOpenedHighlightedReply = false; 41 + String? _activePostUri; 42 + ProviderSubscription<int>? _anchorUpdateSubscription; 43 + ProviderSubscription<int>? _resolvedUpdateSubscription; 49 44 50 45 @override 51 46 void initState() { 52 47 super.initState(); 48 + _anchorUpdateSubscription = ref.listenManual<int>( 49 + postUpdateProvider(widget.postUri), 50 + _handlePostUpdate, 51 + ); 53 52 _loadPost(); 54 53 } 55 54 55 + @override 56 + void dispose() { 57 + _anchorUpdateSubscription?.close(); 58 + _resolvedUpdateSubscription?.close(); 59 + super.dispose(); 60 + } 61 + 56 62 void _loadPost() { 57 - _postFuture = _loadPostWithFallback(); 58 - _postFuture?.then((post) { 63 + _postFuture = _loadResolvedPost(); 64 + _postFuture?.then((resolvedPost) { 59 65 if (mounted) { 60 - _checkContentWarning(post); 61 - _openHighlightedReplyIfNeeded(post); 66 + _activePostUri = resolvedPost.post.uri.toString(); 67 + _bindResolvedPostUpdates(_activePostUri); 68 + _checkContentWarning(resolvedPost.post); 69 + _openHighlightedReplyIfNeeded(resolvedPost); 62 70 } 63 71 }); 64 72 } 65 73 66 - void _openHighlightedReplyIfNeeded(PostView post) { 67 - if (widget.highlightedReplyUri != null && !_hasOpenedHighlightedReply) { 68 - _hasOpenedHighlightedReply = true; 69 - // Open comments modal with highlighted reply after a short delay 70 - // to ensure the page is fully rendered 71 - WidgetsBinding.instance.addPostFrameCallback((_) { 72 - if (mounted) { 73 - context.router.push( 74 - CommentsRoute( 75 - postUri: post.uri.toString(), 76 - isSprk: post.isSprk, 77 - post: post, 78 - highlightedReplyUri: widget.highlightedReplyUri, 79 - ), 80 - ); 81 - } 82 - }); 74 + void _handlePostUpdate(int? previous, int next) { 75 + if (previous == null || previous == next || !mounted) return; 76 + setState(_loadPost); 77 + } 78 + 79 + void _bindResolvedPostUpdates(String? postUri) { 80 + if (postUri == null || postUri == widget.postUri) { 81 + _resolvedUpdateSubscription?.close(); 82 + _resolvedUpdateSubscription = null; 83 + return; 84 + } 85 + 86 + _resolvedUpdateSubscription?.close(); 87 + _resolvedUpdateSubscription = ref.listenManual<int>( 88 + postUpdateProvider(postUri), 89 + _handlePostUpdate, 90 + ); 91 + } 92 + 93 + void _openHighlightedReplyIfNeeded(_ResolvedStandalonePost resolvedPost) { 94 + final targetReplyUri = resolvedPost.targetReplyUri; 95 + if (targetReplyUri == null || _hasOpenedHighlightedReply) return; 96 + 97 + _hasOpenedHighlightedReply = true; 98 + WidgetsBinding.instance.addPostFrameCallback((_) { 99 + _pushHighlightedReplyRoute(resolvedPost, targetReplyUri); 100 + }); 101 + } 102 + 103 + Future<void> _pushHighlightedReplyRoute( 104 + _ResolvedStandalonePost resolvedPost, 105 + String targetReplyUri, { 106 + int attempt = 0, 107 + }) async { 108 + if (!mounted) return; 109 + 110 + final route = ModalRoute.of(context); 111 + final isCurrentRoute = route?.isCurrent ?? false; 112 + if (!isCurrentRoute && attempt < 10) { 113 + await Future<void>.delayed(const Duration(milliseconds: 50)); 114 + return _pushHighlightedReplyRoute( 115 + resolvedPost, 116 + targetReplyUri, 117 + attempt: attempt + 1, 118 + ); 119 + } 120 + 121 + if (!mounted) return; 122 + 123 + final initialChildren = _buildInitialCommentChildren( 124 + resolvedPost, 125 + targetReplyUri, 126 + ); 127 + 128 + context.router.push( 129 + CommentsRoute( 130 + postUri: resolvedPost.post.uri.toString(), 131 + isSprk: resolvedPost.post.isSprk, 132 + post: resolvedPost.post, 133 + highlightedReplyUri: targetReplyUri, 134 + children: initialChildren, 135 + ), 136 + ); 137 + } 138 + 139 + List<PageRouteInfo>? _buildInitialCommentChildren( 140 + _ResolvedStandalonePost resolvedPost, 141 + String targetReplyUri, 142 + ) { 143 + final parentUris = resolvedPost.replyChainUris; 144 + if (parentUris.isEmpty) { 145 + return null; 83 146 } 147 + 148 + return [ 149 + const CommentsListRoute(), 150 + for (var i = 0; i < parentUris.length; i++) 151 + RepliesRoute( 152 + postUri: parentUris[i], 153 + highlightedReplyUri: i == parentUris.length - 1 154 + ? targetReplyUri 155 + : null, 156 + ), 157 + ]; 84 158 } 85 159 86 - Future<PostView> _loadPostWithFallback() async { 160 + Future<_ResolvedStandalonePost> _loadResolvedPost() async { 87 161 final feedRepository = GetIt.instance<SprkRepository>().feed; 88 162 final uri = AtUri.parse(widget.postUri); 89 - final isBlueskyPost = uri.collection.toString().startsWith( 90 - 'app.bsky.feed.post', 163 + 164 + try { 165 + final thread = await feedRepository.getThread( 166 + uri, 167 + depth: 1, 168 + parentHeight: 50, 169 + bluesky: _isBlueskyPost(uri), 170 + ); 171 + 172 + if (thread case ThreadViewPost()) { 173 + return _resolveThreadNavigation(thread); 174 + } 175 + } catch (_) { 176 + // Fallback below preserves existing "open the anchor directly" behavior. 177 + } 178 + 179 + final post = await _loadPostWithFallback(uri); 180 + return _ResolvedStandalonePost(post: post); 181 + } 182 + 183 + Future<_ResolvedStandalonePost> _resolveThreadNavigation( 184 + ThreadViewPost thread, 185 + ) async { 186 + final anchorThread = _findThreadByUri(thread, widget.postUri) ?? thread; 187 + final rawReplyPath = _findReplyPath(thread, widget.postUri); 188 + final replyPath = 189 + rawReplyPath != null && 190 + rawReplyPath.length == 1 && 191 + rawReplyPath.first.post.uri.toString() == widget.postUri && 192 + rawReplyPath.first.parent is ThreadViewPost 193 + ? null 194 + : rawReplyPath; 195 + final rootThread = replyPath?.first ?? _findRootThread(anchorThread); 196 + final displayPost = await _getDisplayPost(rootThread); 197 + final anchorIsReply = 198 + (replyPath != null && replyPath.length > 1) || 199 + anchorThread.parent is ThreadViewPost || 200 + anchorThread.post is ThreadReplyView; 201 + 202 + return _ResolvedStandalonePost( 203 + post: displayPost, 204 + targetReplyUri: anchorIsReply ? widget.postUri : null, 205 + replyChainUris: anchorIsReply 206 + ? (replyPath != null 207 + ? _collectReplyChainUrisFromPath(replyPath) 208 + : _collectIntermediateReplyUris(anchorThread)) 209 + : const <String>[], 91 210 ); 211 + } 212 + 213 + ThreadViewPost? _findThreadByUri( 214 + ThreadViewPost thread, 215 + String targetUri, { 216 + Set<String>? visited, 217 + }) { 218 + final seen = visited ?? <String>{}; 219 + final currentUri = thread.post.uri.toString(); 220 + if (!seen.add(currentUri)) return null; 221 + if (currentUri == targetUri) return thread; 222 + 223 + if (thread.parent case ThreadViewPost parentThread) { 224 + final match = _findThreadByUri(parentThread, targetUri, visited: seen); 225 + if (match != null) { 226 + return match; 227 + } 228 + } 229 + 230 + final replies = thread.replies; 231 + if (replies != null) { 232 + for (final reply in replies) { 233 + if (reply is! ThreadViewPost) continue; 234 + final match = _findThreadByUri(reply, targetUri, visited: seen); 235 + if (match != null) { 236 + return match; 237 + } 238 + } 239 + } 240 + 241 + return null; 242 + } 243 + 244 + List<ThreadViewPost>? _findReplyPath( 245 + ThreadViewPost thread, 246 + String targetUri, { 247 + Set<String>? visited, 248 + }) { 249 + final seen = visited ?? <String>{}; 250 + final currentUri = thread.post.uri.toString(); 251 + if (!seen.add(currentUri)) return null; 252 + if (currentUri == targetUri) return [thread]; 253 + 254 + final replies = thread.replies; 255 + if (replies == null) return null; 256 + 257 + for (final reply in replies) { 258 + if (reply is! ThreadViewPost) continue; 259 + final childPath = _findReplyPath(reply, targetUri, visited: {...seen}); 260 + if (childPath != null) { 261 + return [thread, ...childPath]; 262 + } 263 + } 264 + 265 + return null; 266 + } 267 + 268 + ThreadViewPost _findRootThread(ThreadViewPost thread) { 269 + var current = thread; 270 + while (true) { 271 + final parentThread = current.parent; 272 + if (parentThread is! ThreadViewPost) { 273 + return current; 274 + } 275 + current = parentThread; 276 + } 277 + } 278 + 279 + List<String> _collectIntermediateReplyUris(ThreadViewPost anchorThread) { 280 + final replyUris = <String>[]; 281 + var current = anchorThread.parent; 282 + 283 + while (current is ThreadViewPost) { 284 + final currentPost = current.post; 285 + if (current.parent is ThreadViewPost && currentPost is ThreadReplyView) { 286 + final reply = currentPost.reply; 287 + replyUris.add(reply.uri.toString()); 288 + } 289 + current = current.parent; 290 + } 291 + 292 + return replyUris.reversed.toList(growable: false); 293 + } 294 + 295 + List<String> _collectReplyChainUrisFromPath(List<ThreadViewPost> replyPath) { 296 + if (replyPath.length < 3) return const <String>[]; 297 + return replyPath 298 + .sublist(1, replyPath.length - 1) 299 + .map((thread) => thread.post.uri.toString()) 300 + .toList(growable: false); 301 + } 302 + 303 + Future<PostView> _getDisplayPost(ThreadViewPost rootThread) async { 304 + if (rootThread.post case ThreadPostView(:final post)) { 305 + return post; 306 + } 307 + 308 + return _loadPostWithFallback(rootThread.post.uri); 309 + } 310 + 311 + Future<PostView> _loadPostWithFallback(AtUri uri) async { 312 + final feedRepository = GetIt.instance<SprkRepository>().feed; 92 313 const maxRetries = 3; 93 314 const delay = Duration(seconds: 2); 315 + 94 316 for (var i = 0; i < maxRetries; i++) { 95 317 final networkPost = await feedRepository.getPosts([ 96 318 uri, 97 - ], bluesky: isBlueskyPost); 319 + ], bluesky: _isBlueskyPost(uri)); 98 320 if (networkPost.isNotEmpty) { 99 321 return networkPost.first; 100 322 } ··· 102 324 await Future.delayed(delay); 103 325 } 104 326 } 327 + 105 328 throw Exception('Failed to load post after $maxRetries attempts'); 329 + } 330 + 331 + bool _isBlueskyPost(AtUri uri) { 332 + return uri.collection.toString().startsWith('app.bsky.feed.post'); 106 333 } 107 334 108 335 void _checkContentWarning(PostView postData) { ··· 142 369 @override 143 370 Widget build(BuildContext context) { 144 371 final l10n = AppLocalizations.of(context); 145 - // Watch for post updates to trigger reload 146 - final updateCount = ref.watch(postUpdateProvider(widget.postUri)); 147 372 148 - if (_lastUpdateCount != updateCount) { 149 - _lastUpdateCount = updateCount; 150 - WidgetsBinding.instance.addPostFrameCallback((_) { 151 - if (mounted) { 152 - setState(_loadPost); 153 - } 154 - }); 155 - } 156 - 157 - return FutureBuilder<PostView>( 373 + return FutureBuilder<_ResolvedStandalonePost>( 158 374 future: _postFuture, 159 375 builder: (context, snapshot) { 160 - final postData = snapshot.data; 376 + final resolvedPost = snapshot.data; 377 + final postData = resolvedPost?.post; 161 378 final bottomPadding = MediaQuery.of(context).padding.bottom; 162 379 Widget content; 163 380 ··· 290 507 }, 291 508 ); 292 509 } 510 + } 511 + 512 + class _ResolvedStandalonePost { 513 + const _ResolvedStandalonePost({ 514 + required this.post, 515 + this.targetReplyUri, 516 + this.replyChainUris = const <String>[], 517 + }); 518 + 519 + final PostView post; 520 + final String? targetReplyUri; 521 + final List<String> replyChainUris; 293 522 } 294 523 295 524 class _CommentBar extends StatelessWidget {
+1 -105
lib/src/features/notifications/ui/widgets/notification_item.dart
··· 1 1 import 'dart:async'; 2 2 3 - import 'package:atproto_core/atproto_core.dart'; 4 3 import 'package:auto_route/auto_route.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 - import 'package:get_it/get_it.dart'; 8 6 import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 9 7 import 'package:spark/src/core/network/atproto/data/models/notification_models.dart' 10 8 as models; 11 - import 'package:spark/src/core/network/atproto/data/models/record_models.dart' 12 - hide Image; 13 - import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 14 9 import 'package:spark/src/core/routing/app_router.dart'; 15 10 import 'package:spark/src/core/ui/foundation/colors.dart'; 16 11 import 'package:spark/src/core/ui/widgets/user_avatar.dart'; ··· 36 31 class _NotificationItemState extends ConsumerState<NotificationItem> { 37 32 bool _hasBeenViewed = false; 38 33 Timer? _viewTimer; 39 - 40 - SprkRepository get _sprkRepository => GetIt.instance<SprkRepository>(); 41 34 42 35 /// The primary notification (most recent in the group) 43 36 models.Notification get notification => ··· 182 175 // Navigate to profile (use first author for grouped follows) 183 176 context.router.push(ProfileRoute(did: notification.author.did)); 184 177 } else if (notification.reason == 'reply') { 185 - // Reply notification - navigate to root post with reply highlighted 186 178 final replyUri = notification.uri.toString(); 187 - final rootPostUri = _getRootPostUri(); 188 - if (rootPostUri != null) { 189 - context.router.push( 190 - StandalonePostRoute( 191 - postUri: rootPostUri, 192 - highlightedReplyUri: replyUri, 193 - ), 194 - ); 195 - } else { 196 - // Fallback to standalone post 197 - context.router.push(StandalonePostRoute(postUri: replyUri)); 198 - } 199 - } else if (notification.reason == 'like' && _isReplySubject()) { 200 - // Like on a reply - get root post URI from embedded subject or fetch it 201 - final replyUri = notification.reasonSubject!.toString(); 202 - 203 - // First try to get root from embedded subject record 204 - var rootPostUri = _getRootPostUriFromEmbeddedSubject(); 205 - 206 - // If not available, fetch the reply record 207 - rootPostUri ??= await _fetchRootPostUriFromReply(replyUri); 208 - 209 - if (rootPostUri != null && context.mounted) { 210 - context.router.push( 211 - StandalonePostRoute( 212 - postUri: rootPostUri, 213 - highlightedReplyUri: replyUri, 214 - ), 215 - ); 216 - } else if (context.mounted) { 217 - // Fallback to standalone post showing the reply 218 - context.router.push(StandalonePostRoute(postUri: replyUri)); 219 - } 179 + context.router.push(StandalonePostRoute(postUri: replyUri)); 220 180 } else if (notification.reasonSubject != null) { 221 181 // Navigate to the post/thread 222 182 final reasonSubjectStr = notification.reasonSubject!.toString(); ··· 233 193 // Fallback to author profile 234 194 context.router.push(ProfileRoute(did: notification.author.did)); 235 195 } 236 - } 237 - 238 - /// Check if the reasonSubject is a reply (not a post) 239 - bool _isReplySubject() { 240 - if (notification.reasonSubject == null) return false; 241 - final collection = notification.reasonSubject!.collection.toString(); 242 - return collection.contains('reply'); 243 - } 244 - 245 - /// Get the root post URI from a reply notification's record 246 - String? _getRootPostUri() { 247 - try { 248 - final record = notification.record; 249 - final reply = record['reply'] as Map<String, dynamic>?; 250 - if (reply != null) { 251 - final root = reply['root'] as Map<String, dynamic>?; 252 - if (root != null) { 253 - return root['uri'] as String?; 254 - } 255 - } 256 - } catch (e) { 257 - // Ignore parsing errors 258 - } 259 - return null; 260 - } 261 - 262 - /// Get the root post URI from the embedded subject record (for like/repost on reply) 263 - String? _getRootPostUriFromEmbeddedSubject() { 264 - try { 265 - final record = notification.record; 266 - // The backend embeds the subject record in notification.record['subject'] 267 - final subject = record['subject'] as Map<String, dynamic>?; 268 - if (subject != null) { 269 - final reply = subject['reply'] as Map<String, dynamic>?; 270 - if (reply != null) { 271 - final root = reply['root'] as Map<String, dynamic>?; 272 - if (root != null) { 273 - return root['uri'] as String?; 274 - } 275 - } 276 - } 277 - } catch (e) { 278 - // Ignore parsing errors 279 - } 280 - return null; 281 - } 282 - 283 - /// Fetch the reply record and extract the root post URI 284 - Future<String?> _fetchRootPostUriFromReply(String replyUriStr) async { 285 - try { 286 - final replyUri = AtUri.parse(replyUriStr); 287 - final result = await _sprkRepository.repo.getRecord(uri: replyUri); 288 - final record = result.record; 289 - 290 - // Check if it's a reply record and extract the root URI 291 - if (record is ReplyRecord) { 292 - return record.reply.root.uri.toString(); 293 - } else if (record is BskyPostRecord && record.reply != null) { 294 - return record.reply!.root.uri.toString(); 295 - } 296 - } catch (e) { 297 - // If we can't fetch the record, return null to use fallback 298 - } 299 - return null; 300 196 } 301 197 302 198 String? _getContentPreview() {