mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

fix: read after write replies

+266 -26
+51 -15
README.md
··· 2 2 3 3 ![Lazurite Hero](./docs/images/hero.png) 4 4 5 - Lazurite is a cross-platform Bluesky client built with Flutter and Dart using Material You (M3) design. 5 + Lazurite is a cross-platform Bluesky client that *rocks*[^1] built with Flutter and Dart using Material You (M3) design. 6 6 7 7 ## Features 8 8 ··· 28 28 - **Search History:** Persisted local search history. 29 29 - **Saved Feeds:** Manage and pin your favorite feeds. 30 30 31 + ## What Lazurite Offers Beyond Bluesky 32 + 33 + ### Available Now 34 + 35 + - **Semantic Search:** On-device vector embeddings (all-MiniLM-L6-v2) let you search saved 36 + and liked posts by meaning, not just keywords. 37 + - **Post Scheduling:** Write posts now, publish them later. 38 + - **Follow Audit:** Bulk-analyze your follows to find deleted, deactivated, suspended, or 39 + blocking accounts. Batch unfollow in one tap like [clean follows](https://cleanfollow-bsky.pages.dev/) 40 + - **Constellation Integration:** See who has blocked you and which lists you appear on, 41 + powered by [Constellation](https://constellation.microcosm.blue) backlinks. 42 + - **AT Protocol Dev Tools:** Browse any user's PDS repository, inspect collections and 43 + individual records as JSON, like an in-app [pds.ls](https://pds.ls/). 44 + - **Rich Theming:** Five full palettes (Lazurite™️[^2], Rose Pine, Catppuccin, Nord, Oxocarbon), 45 + each with light and dark variants, built on Material 3. 46 + - **Offline First:** First page of feeds is cached locally; drafts, search history, and saved 47 + posts persist in an on-device database. 48 + - **Local Drafts:** Auto-saved to the database, surviving crashes and force-closes. Multiple 49 + drafts per account with full reply/quote/media context. 50 + - **Layout Options:** Toggle between Card and Compact feed views. Configure thread 51 + auto-collapse depth (off, 1–6 levels). 52 + - **In-App Logs:** Filter by level, full-text search, share or export, useful for 53 + debugging and AT Protocol development. 54 + 55 + ### On the Roadmap 56 + 57 + - **RSS Feed Export:** View and export any public Bluesky profile as an RSS feed. 58 + - **Custom Fonts:** User-selectable serif, sans-serif, and monospace typefaces across the 59 + entire app. 60 + - **Markdown Posts:** Toggleable Markdown rendering in post bodies. 61 + - **Firehose & Jetstream Viewers:** Live AT Protocol event streams inside Dev Tools. 62 + - **Auto-Threading:** Automatically split long posts into threaded replies. 63 + - **Last Read Position:** Resume your timeline exactly where you left off. 64 + 31 65 ## Architecture 32 66 33 67 ### Stack ··· 51 85 52 86 ### Data Flow 53 87 54 - - **Network:** Authenticated requests are routed through user PDS; public reads use the public AppView. 55 - - **Database:** Drift manages local persistence for accounts, cached profiles/posts, settings, and drafts. 88 + ```mermaid 89 + flowchart LR 90 + router["App Navigator/Router (go_router)"] <--> ui["Feature UI"] 91 + ui <--> bloc["BLoC"] 92 + bloc <--> repo["Repository Classes (Data Layer)"] 93 + repo <--> pds["Authenticated API (User PDS)"] 94 + repo <--> appview["Public API (AppView)"] 95 + repo <--> local["On-device Database (SQLite/Drift)"] 56 96 57 - ### Routing 58 - 59 - Lazurite uses `StatefulShellRoute` for persistent bottom navigation. 60 - 61 - | Path | Description | 62 - | ----------- | ------------------------------ | 63 - | `/login` | Authentication gateway | 64 - | `/` | Home Feed tab | 65 - | `/search` | Search tab | 66 - | `/profile` | Current user profile tab | 67 - | `/settings` | Global settings | 68 - | `/compose` | Root-level modal for new posts | 97 + classDef primary fill:#0b63d1,stroke:#0953af,color:#ffffff,stroke-width:1px; 98 + classDef surface fill:#f4f6f9,stroke:#45505e,color:#101418,stroke-width:1px; 99 + class router,ui,bloc,repo primary; 100 + class pds,appview,local surface; 101 + ``` 69 102 70 103 For development setup, tooling, database schema, and contribution notes, see [DEVELOPMENT.md](DEVELOPMENT.md). 71 104 ··· 81 114 - Custom theming inspired by [Witchsky](https://witchsky.app/). 82 115 - DevTools (AT Protocol Explorer) inspiration from [pdsls](https://pds.ls/) 83 116 - AT URI links pass through [aturi.to](https://aturi.to/) 117 + 118 + [^1]: It's actually a mineral <https://en.wikipedia.org/wiki/Lazurite> 119 + [^2]: not really trademarked, actually a cool theme that you can find in the [desktop flavor](https://github.com/stormlightlabs/lazurite-desktop) too.
+25
docs/TODO.md
··· 75 75 76 76 - Last read position 77 77 - Autothreading of posts over char limit; splitting posts 78 + 79 + --- 80 + 81 + - **Advanced Mute Filters:** Mute by regex pattern, time-limited mutes (e.g. mute for 24h), 82 + and mute entire threads. 83 + - **Post Templates:** Save reusable post templates (e.g. recurring "what are you reading" 84 + threads) for quick composition. 85 + - **Thread Bookmarks:** Save your position in long threads and resume reading later. 86 + - **Read-It-Later Queue:** A dedicated queue for posts you want to come back to, separate 87 + from saved posts. 88 + - **Batch Actions:** Select multiple posts to save, delete, or export (as JSON) in bulk. 89 + 90 + ### Data Ownership 91 + 92 + - **Account Data Export:** Export your posts, follows, likes, and saved posts to JSON/CSV. 93 + - **Account Data Import:** Migrate saved posts, drafts, and settings between accounts. 94 + 95 + ### AT Protocol (in the explorer) 96 + 97 + - **Custom Feed Filters:** Layer client-side filters on top of any feed generator (hide 98 + reposts, minimum engagement threshold, language filter). 99 + - **DID History Viewer:** Inspect the rotation history and recovery keys for any DID in the 100 + network. 101 + - **Labeler Comparison:** Side-by-side view of how different labelers classify the same 102 + content or account.
+10 -1
lib/features/compose/presentation/compose_screen.dart
··· 502 502 if (state.isEditing) { 503 503 showAppSnackBar(context, 'Changes saved.', behavior: SnackBarBehavior.floating); 504 504 } 505 - Navigator.of(context).pop(state.isEditing ? {'editedText': state.text} : null); 505 + Navigator.of(context).pop( 506 + state.isEditing 507 + ? {'editedText': state.text} 508 + : { 509 + 'status': state.hasScheduledTime ? 'scheduled' : 'posted', 510 + 'isReply': state.isReply, 511 + 'replyParentUri': state.replyParentUri, 512 + 'replyRootUri': state.replyRootUri, 513 + }, 514 + ); 506 515 } 507 516 508 517 if (state.hasError && state.errorMessage != null) {
+98 -5
lib/features/feed/presentation/post_thread_screen.dart
··· 126 126 }); 127 127 } 128 128 129 + Future<void> _reloadThreadAfterReply(String replyParentUri) async { 130 + final cubit = context.read<PostThreadCubit>(); 131 + final before = cubit.state.thread == null ? null : _snapshotForPostUri(cubit.state.thread!, replyParentUri); 132 + final retryDelays = <Duration>[ 133 + Duration.zero, 134 + const Duration(seconds: 1), 135 + const Duration(seconds: 2), 136 + const Duration(seconds: 4), 137 + ]; 138 + 139 + for (final delay in retryDelays) { 140 + if (delay > Duration.zero) { 141 + await Future<void>.delayed(delay); 142 + } 143 + if (!mounted) return; 144 + 145 + await cubit.load(widget.postUri); 146 + final loadedThread = cubit.state.thread; 147 + if (loadedThread == null) { 148 + continue; 149 + } 150 + 151 + final after = _snapshotForPostUri(loadedThread, replyParentUri); 152 + if (before == null || after == null) { 153 + return; 154 + } 155 + 156 + final replyCountIncreased = after.directReplyCount > before.directReplyCount; 157 + final descendantCountIncreased = after.descendantReplyCount > before.descendantReplyCount; 158 + if (replyCountIncreased || descendantCountIncreased) { 159 + return; 160 + } 161 + } 162 + 163 + if (mounted) { 164 + showAppSnackBar( 165 + context, 166 + 'Reply posted. It may take a moment to appear in this thread.', 167 + behavior: SnackBarBehavior.floating, 168 + ); 169 + } 170 + } 171 + 129 172 @override 130 173 Widget build(BuildContext context) { 131 174 return BlocListener<PostThreadCubit, PostThreadState>( ··· 183 226 PostCardWithActions( 184 227 feedViewPost: FeedViewPost(post: parents[i].post), 185 228 accountDid: accountDid, 229 + onReplySubmitted: _reloadThreadAfterReply, 186 230 moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 187 231 ), 188 232 _buildThreadConnector(context), 189 233 ], 190 - _FocusedPostWithActions(thread: thread, accountDid: accountDid), 234 + _FocusedPostWithActions(thread: thread, accountDid: accountDid, onReplySubmitted: _reloadThreadAfterReply), 191 235 if (replies.isNotEmpty) ...[ 192 236 Padding( 193 237 padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), ··· 210 254 opDid: opDid, 211 255 collapsedUris: _collapsedUris, 212 256 onToggleCollapse: _toggleCollapsed, 257 + onReplySubmitted: _reloadThreadAfterReply, 213 258 ), 214 259 ], 215 260 ], ··· 249 294 required this.opDid, 250 295 required this.collapsedUris, 251 296 required this.onToggleCollapse, 297 + this.onReplySubmitted, 252 298 this.onContinueThread, 253 299 }); 254 300 ··· 258 304 final String opDid; 259 305 final Set<String> collapsedUris; 260 306 final ValueChanged<String> onToggleCollapse; 307 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 261 308 final ValueChanged<ThreadViewPost>? onContinueThread; 262 309 263 310 @override ··· 295 342 opDid: opDid, 296 343 collapsedUris: collapsedUris, 297 344 onToggleCollapse: onToggleCollapse, 345 + onReplySubmitted: onReplySubmitted, 298 346 onContinueThread: onContinueThread, 299 347 ), 300 348 ), ··· 327 375 required this.opDid, 328 376 required this.collapsedUris, 329 377 required this.onToggleCollapse, 378 + this.onReplySubmitted, 330 379 this.onContinueThread, 331 380 }); 332 381 ··· 336 385 final String opDid; 337 386 final Set<String> collapsedUris; 338 387 final ValueChanged<String> onToggleCollapse; 388 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 339 389 final ValueChanged<ThreadViewPost>? onContinueThread; 340 390 341 391 @override ··· 353 403 PostCardWithActions( 354 404 feedViewPost: FeedViewPost(post: thread.post), 355 405 accountDid: accountDid, 406 + onReplySubmitted: onReplySubmitted, 356 407 moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 357 408 ), 358 409 for (final reply in replies) ··· 364 415 opDid: opDid, 365 416 collapsedUris: collapsedUris, 366 417 onToggleCollapse: onToggleCollapse, 418 + onReplySubmitted: onReplySubmitted, 367 419 onContinueThread: onContinueThread, 368 420 ), 369 421 ], ··· 576 628 return count; 577 629 } 578 630 631 + _ReplyThreadSnapshot? _snapshotForPostUri(ThreadViewPost thread, String postUri) { 632 + if (thread.post.uri.toString() == postUri) { 633 + return _ReplyThreadSnapshot( 634 + directReplyCount: thread.post.replyCount ?? 0, 635 + descendantReplyCount: _countDescendantReplies(thread), 636 + ); 637 + } 638 + 639 + for (final reply in _threadRepliesOf(thread)) { 640 + final nested = _snapshotForPostUri(reply, postUri); 641 + if (nested != null) { 642 + return nested; 643 + } 644 + } 645 + 646 + return null; 647 + } 648 + 649 + class _ReplyThreadSnapshot { 650 + const _ReplyThreadSnapshot({required this.directReplyCount, required this.descendantReplyCount}); 651 + 652 + final int directReplyCount; 653 + final int descendantReplyCount; 654 + } 655 + 579 656 String _hiddenReplyLabel(int count) => count == 1 ? '1 reply hidden' : '$count replies hidden'; 580 657 581 658 List<Color> _threadLineColors(BuildContext context) { ··· 614 691 } 615 692 616 693 class _FocusedPostWithActions extends StatelessWidget { 617 - const _FocusedPostWithActions({required this.thread, required this.accountDid}); 694 + const _FocusedPostWithActions({required this.thread, required this.accountDid, this.onReplySubmitted}); 618 695 619 696 final ThreadViewPost thread; 620 697 final String accountDid; 698 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 621 699 622 700 @override 623 701 Widget build(BuildContext context) { ··· 643 721 showAppSnackBar(context, state.error!, behavior: SnackBarBehavior.floating); 644 722 context.read<PostActionCubit>().clearError(); 645 723 }, 646 - child: _FocusedPostContent(thread: thread, accountDid: accountDid), 724 + child: _FocusedPostContent(thread: thread, accountDid: accountDid, onReplySubmitted: onReplySubmitted), 647 725 ), 648 726 ); 649 727 } 650 728 } 651 729 652 730 class _FocusedPostContent extends StatelessWidget { 653 - const _FocusedPostContent({required this.thread, required this.accountDid}); 731 + const _FocusedPostContent({required this.thread, required this.accountDid, this.onReplySubmitted}); 654 732 655 733 final ThreadViewPost thread; 656 734 final String accountDid; 735 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 657 736 658 737 @override 659 738 Widget build(BuildContext context) { ··· 795 874 } 796 875 797 876 void _onReply(BuildContext context) { 877 + unawaited(_handleReply(context)); 878 + } 879 + 880 + Future<void> _handleReply(BuildContext context) async { 798 881 HapticHelper.selectionClick(); 799 882 final post = thread.post; 800 883 final root = _findRoot(); 801 884 802 - context.push( 885 + final result = await context.push( 803 886 '/compose', 804 887 extra: ComposeRouteArgs( 805 888 replyParentUri: post.uri.toString(), ··· 809 892 replyAuthorHandle: post.author.handle, 810 893 ), 811 894 ); 895 + 896 + if (!context.mounted) return; 897 + if (!_didCreateImmediateReply(result)) return; 898 + 899 + await onReplySubmitted?.call(post.uri.toString()); 900 + } 901 + 902 + bool _didCreateImmediateReply(Object? result) { 903 + if (result is! Map) return false; 904 + return result['status'] == 'posted' && result['isReply'] == true; 812 905 } 813 906 814 907 void _onQuote(BuildContext context) {
+20 -1
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 28 28 required this.accountDid, 29 29 this.variant = PostCardVariant.linear, 30 30 this.onDeleted, 31 + this.onReplySubmitted, 31 32 this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, 32 33 }); 33 34 ··· 35 36 final String accountDid; 36 37 final PostCardVariant variant; 37 38 final VoidCallback? onDeleted; 39 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 38 40 final bsky_moderation.ModerationBehaviorContext moderationContext; 39 41 40 42 @override ··· 60 62 accountDid: accountDid, 61 63 variant: variant, 62 64 onDeleted: onDeleted, 65 + onReplySubmitted: onReplySubmitted, 63 66 moderationContext: moderationContext, 64 67 ), 65 68 ); ··· 72 75 required this.accountDid, 73 76 required this.variant, 74 77 this.onDeleted, 78 + this.onReplySubmitted, 75 79 required this.moderationContext, 76 80 }); 77 81 ··· 79 83 final String accountDid; 80 84 final PostCardVariant variant; 81 85 final VoidCallback? onDeleted; 86 + final Future<void> Function(String replyParentUri)? onReplySubmitted; 82 87 final bsky_moderation.ModerationBehaviorContext moderationContext; 83 88 84 89 @override ··· 171 176 } 172 177 173 178 void _onReply(BuildContext context) { 179 + unawaited(_handleReply(context)); 180 + } 181 + 182 + Future<void> _handleReply(BuildContext context) async { 174 183 HapticHelper.selectionClick(); 175 184 final post = feedViewPost.post; 176 185 final reply = feedViewPost.reply; ··· 186 195 rootCid = post.cid; 187 196 } 188 197 189 - context.push( 198 + final result = await context.push( 190 199 '/compose', 191 200 extra: ComposeRouteArgs( 192 201 replyParentUri: post.uri.toString(), ··· 196 205 replyAuthorHandle: post.author.handle, 197 206 ), 198 207 ); 208 + 209 + if (!context.mounted) return; 210 + if (!_didCreateImmediateReply(result)) return; 211 + 212 + await onReplySubmitted?.call(post.uri.toString()); 213 + } 214 + 215 + bool _didCreateImmediateReply(Object? result) { 216 + if (result is! Map) return false; 217 + return result['status'] == 'posted' && result['isReply'] == true; 199 218 } 200 219 201 220 Future<void> _onToggleSave(BuildContext context) async {
+62 -4
test/features/feed/presentation/post_thread_edit_flow_test.dart
··· 75 75 required String handle, 76 76 required String rkey, 77 77 required String text, 78 + int? replyCount, 78 79 DateTime? createdAt, 79 80 }) { 80 81 final time = createdAt ?? DateTime.utc(2026, 4, 14, 12); ··· 84 85 author: ProfileViewBasic(did: did, handle: handle), 85 86 record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': time.toIso8601String()}, 86 87 indexedAt: time, 88 + replyCount: replyCount, 87 89 ); 88 90 } 89 91 90 - ThreadViewPost _makeThread({required String did, required String handle, required String rkey, required String text}) { 92 + ThreadViewPost _makeThread({ 93 + required String did, 94 + required String handle, 95 + required String rkey, 96 + required String text, 97 + int? replyCount, 98 + List<ThreadViewPost> replies = const [], 99 + }) { 91 100 return ThreadViewPost( 92 - post: _makePost(did: did, handle: handle, rkey: rkey, text: text), 101 + post: _makePost(did: did, handle: handle, rkey: rkey, text: text, replyCount: replyCount), 102 + replies: replies.map((reply) => UThreadViewPostReplies.threadViewPost(data: reply)).toList(), 93 103 ); 94 104 } 95 105 ··· 133 143 required String accountDid, 134 144 required ThreadViewPost thread, 135 145 required ValueSetter<ComposeRouteArgs> onComposeArgs, 146 + Object? composePopResult = true, 147 + bool stubThreadLoad = true, 136 148 }) { 137 149 final router = GoRouter( 138 150 routes: [ ··· 149 161 body: Center( 150 162 child: ElevatedButton( 151 163 key: const ValueKey('complete-edit'), 152 - onPressed: () => Navigator.of(context).pop(true), 164 + onPressed: () => Navigator.of(context).pop(composePopResult), 153 165 child: const Text('Complete Edit'), 154 166 ), 155 167 ), ··· 159 171 ], 160 172 ); 161 173 162 - when(() => postThreadRepository.getPostThread(postUri)).thenAnswer((_) async => thread); 174 + if (stubThreadLoad) { 175 + when(() => postThreadRepository.getPostThread(postUri)).thenAnswer((_) async => thread); 176 + } 163 177 164 178 return MultiRepositoryProvider( 165 179 providers: [ ··· 248 262 await tester.pumpAndSettle(); 249 263 250 264 verify(() => postThreadRepository.getPostThread(postUri)).called(1); 265 + }); 266 + 267 + testWidgets('reloads thread after reply flow returns posted result', (tester) async { 268 + final initialThread = _makeThread( 269 + did: 'did:plc:owner', 270 + handle: 'owner.bsky.social', 271 + rkey: 'root', 272 + text: 'Original post body', 273 + replyCount: 0, 274 + ); 275 + final refreshedThread = _makeThread( 276 + did: 'did:plc:owner', 277 + handle: 'owner.bsky.social', 278 + rkey: 'root', 279 + text: 'Original post body', 280 + replyCount: 1, 281 + replies: [_makeThread(did: 'did:plc:replier', handle: 'replier.bsky.social', rkey: 'child', text: 'New reply')], 282 + ); 283 + 284 + var loadCount = 0; 285 + when(() => postThreadRepository.getPostThread(postUri)).thenAnswer((_) async { 286 + loadCount += 1; 287 + return loadCount >= 2 ? refreshedThread : initialThread; 288 + }); 289 + 290 + await tester.pumpWidget( 291 + createSubjectWidget( 292 + accountDid: 'did:plc:owner', 293 + thread: initialThread, 294 + onComposeArgs: (_) {}, 295 + composePopResult: {'status': 'posted', 'isReply': true, 'replyParentUri': postUri, 'replyRootUri': postUri}, 296 + stubThreadLoad: false, 297 + ), 298 + ); 299 + await tester.pumpAndSettle(); 300 + 301 + verify(() => postThreadRepository.getPostThread(postUri)).called(1); 302 + 303 + await tester.tap(find.byIcon(Icons.chat_bubble_outline).first); 304 + await tester.pumpAndSettle(); 305 + await tester.tap(find.byKey(const ValueKey('complete-edit'))); 306 + await tester.pumpAndSettle(); 307 + 308 + expect(loadCount, greaterThanOrEqualTo(2)); 251 309 }); 252 310 }