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.

feat: render facets in profile bio/desc

* better/more exhaustive embed rendering

+1192 -147
+1
lib/core/router/app_router.dart
··· 141 141 quoteUri: args.quoteUri, 142 142 quoteCid: args.quoteCid, 143 143 quoteAuthorHandle: args.quoteAuthorHandle, 144 + quoteText: args.quoteText, 144 145 draftId: args.draftId, 145 146 initialText: args.initialText, 146 147 editPostUri: args.editPostUri,
+128 -14
lib/features/compose/bloc/compose_bloc.dart
··· 14 14 import 'package:lazurite/core/database/app_database.dart'; 15 15 import 'package:lazurite/core/logging/app_logger.dart'; 16 16 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 17 + import 'package:lazurite/features/compose/data/link_preview_service.dart'; 17 18 18 19 part 'compose_event.dart'; 19 20 part 'compose_state.dart'; ··· 457 458 return; 458 459 } 459 460 460 - Map<String, dynamic>? embed; 461 + Map<String, dynamic>? mediaEmbed; 461 462 462 463 if (state.mediaAttachments.isNotEmpty) { 463 464 final uploaded = <_UploadedImage>[]; ··· 496 497 ); 497 498 } 498 499 499 - embed = { 500 + mediaEmbed = { 500 501 '\$type': 'app.bsky.embed.images', 501 502 'images': uploaded.map((img) { 502 503 final entry = <String, dynamic>{'image': img.blobRef.toJson(), 'alt': img.altText}; ··· 508 509 }; 509 510 } else if (state.videoAttachment?.isReady == true) { 510 511 final blob = state.videoAttachment!.blob!; 511 - embed = {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': state.videoAttachment!.altText}; 512 - } else if (state.quoteUri != null && state.quoteCid != null) { 513 - embed = { 514 - r'$type': 'app.bsky.embed.record', 515 - 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, 516 - }; 512 + mediaEmbed = {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': state.videoAttachment!.altText}; 513 + } else { 514 + final firstLink = LinkPreviewService.firstLink(state.text); 515 + if (firstLink != null && firstLink != event.suppressedLinkUri) { 516 + mediaEmbed = await _composeRepository.buildExternalEmbedFromLink(firstLink); 517 + } 518 + } 519 + 520 + Map<String, dynamic>? embed; 521 + if (state.quoteUri != null && state.quoteCid != null) { 522 + if (mediaEmbed != null) { 523 + embed = { 524 + r'$type': 'app.bsky.embed.recordWithMedia', 525 + 'record': { 526 + r'$type': 'app.bsky.embed.record', 527 + 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, 528 + }, 529 + 'media': mediaEmbed, 530 + }; 531 + } else { 532 + embed = { 533 + r'$type': 'app.bsky.embed.record', 534 + 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, 535 + }; 536 + } 537 + } else { 538 + embed = mediaEmbed; 517 539 } 518 540 519 541 Map<String, dynamic>? reply; 520 542 if (state.replyParentUri != null && state.replyParentCid != null) { 543 + final fallbackRootUri = state.replyRootUri ?? state.replyParentUri!; 544 + final fallbackRootCid = state.replyRootCid ?? state.replyParentCid!; 545 + final resolvedReplyRefs = await _composeRepository.resolveReplyReferences( 546 + parentUri: state.replyParentUri!, 547 + parentCid: state.replyParentCid!, 548 + fallbackRootUri: fallbackRootUri, 549 + fallbackRootCid: fallbackRootCid, 550 + ); 551 + 552 + final parentCid = resolvedReplyRefs?.parentCid ?? state.replyParentCid!; 553 + final rootUri = resolvedReplyRefs?.rootUri ?? fallbackRootUri; 554 + final rootCid = resolvedReplyRefs?.rootCid ?? fallbackRootCid; 555 + 521 556 reply = { 522 - 'parent': {'uri': state.replyParentUri, 'cid': state.replyParentCid}, 523 - 'root': { 524 - 'uri': state.replyRootUri ?? state.replyParentUri, 525 - 'cid': state.replyRootCid ?? state.replyParentCid, 526 - }, 557 + 'parent': {'uri': state.replyParentUri, 'cid': parentCid}, 558 + 'root': {'uri': rootUri, 'cid': rootCid}, 527 559 }; 528 560 } 529 561 ··· 667 699 } 668 700 669 701 class ComposeRepository { 670 - ComposeRepository({required Bluesky bluesky}) : _bluesky = bluesky; 702 + ComposeRepository({required Bluesky bluesky, LinkPreviewService? linkPreviewService}) 703 + : _bluesky = bluesky, 704 + _linkPreviewService = linkPreviewService ?? LinkPreviewService(); 671 705 672 706 final Bluesky _bluesky; 707 + final LinkPreviewService _linkPreviewService; 673 708 674 709 Future<BlobRef?> uploadBlob(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 675 710 try { ··· 739 774 } catch (e, stackTrace) { 740 775 log.e('Failed to create post', error: e, stackTrace: stackTrace); 741 776 return false; 777 + } 778 + } 779 + 780 + Future<LinkPreviewData?> fetchLinkPreview(String rawUrl) async { 781 + try { 782 + return await _linkPreviewService.fetch(rawUrl); 783 + } catch (error, stackTrace) { 784 + log.w('Failed to fetch link preview metadata', error: error, stackTrace: stackTrace); 785 + return null; 786 + } 787 + } 788 + 789 + Future<Map<String, dynamic>?> buildExternalEmbedFromLink(String rawUrl) async { 790 + final preview = await fetchLinkPreview(rawUrl); 791 + if (preview == null) { 792 + return null; 793 + } 794 + 795 + final external = <String, dynamic>{'uri': preview.uri, 'title': preview.title, 'description': preview.description}; 796 + 797 + final thumbUrl = preview.thumbnailUrl; 798 + if (thumbUrl != null && thumbUrl.isNotEmpty) { 799 + final thumb = await _uploadExternalThumb(thumbUrl); 800 + if (thumb != null) { 801 + external['thumb'] = thumb.toJson(); 802 + } 803 + } 804 + 805 + return {r'$type': 'app.bsky.embed.external', 'external': external}; 806 + } 807 + 808 + Future<({String parentCid, String rootUri, String rootCid})?> resolveReplyReferences({ 809 + required String parentUri, 810 + required String parentCid, 811 + required String fallbackRootUri, 812 + required String fallbackRootCid, 813 + }) async { 814 + try { 815 + final parentAtUri = AtUri.parse(parentUri); 816 + final parent = await _bluesky.atproto.repo.getRecord( 817 + repo: parentAtUri.hostname, 818 + collection: parentAtUri.collection.toString(), 819 + rkey: parentAtUri.rkey, 820 + ); 821 + 822 + final latestParentCid = parent.data.cid ?? parentCid; 823 + final parentReply = parent.data.value['reply']; 824 + if (parentReply is! Map) { 825 + return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); 826 + } 827 + 828 + final rootRef = parentReply['root']; 829 + if (rootRef is! Map) { 830 + return (parentCid: latestParentCid, rootUri: fallbackRootUri, rootCid: fallbackRootCid); 831 + } 832 + 833 + final rootUri = rootRef['uri']; 834 + final rootCid = rootRef['cid']; 835 + if (rootUri is String && rootCid is String && rootUri.isNotEmpty && rootCid.isNotEmpty) { 836 + return (parentCid: latestParentCid, rootUri: rootUri, rootCid: rootCid); 837 + } 838 + 839 + return (parentCid: latestParentCid, rootUri: fallbackRootUri, rootCid: fallbackRootCid); 840 + } catch (error, stackTrace) { 841 + log.w('Failed to resolve reply references; using fallback refs', error: error, stackTrace: stackTrace); 842 + return null; 843 + } 844 + } 845 + 846 + Future<BlobRef?> _uploadExternalThumb(String thumbUrl) async { 847 + try { 848 + final thumb = await _linkPreviewService.fetchThumbnail(thumbUrl); 849 + if (thumb == null) { 850 + return null; 851 + } 852 + return await uploadBlob(thumb.bytes, mimeType: thumb.mimeType); 853 + } catch (error, stackTrace) { 854 + log.w('Failed to upload external embed thumbnail blob', error: error, stackTrace: stackTrace); 855 + return null; 742 856 } 743 857 } 744 858
+6 -1
lib/features/compose/bloc/compose_event.dart
··· 108 108 } 109 109 110 110 class PostSubmitted extends ComposeEvent { 111 - const PostSubmitted(); 111 + const PostSubmitted({this.suppressedLinkUri}); 112 + 113 + final String? suppressedLinkUri; 114 + 115 + @override 116 + List<Object?> get props => [suppressedLinkUri]; 112 117 } 113 118 114 119 class ReplyContextSet extends ComposeEvent {
+3
lib/features/compose/presentation/compose_route_args.dart
··· 41 41 quoteUri: readString('quoteUri'), 42 42 quoteCid: readString('quoteCid'), 43 43 quoteAuthorHandle: readString('quoteAuthorHandle'), 44 + quoteText: readString('quoteText'), 44 45 draftId: readInt('draftId'), 45 46 initialText: readString('initialText'), 46 47 editPostUri: readString('editPostUri'), ··· 60 61 this.quoteUri, 61 62 this.quoteCid, 62 63 this.quoteAuthorHandle, 64 + this.quoteText, 63 65 this.draftId, 64 66 this.initialText, 65 67 this.editPostUri, ··· 75 77 final String? quoteUri; 76 78 final String? quoteCid; 77 79 final String? quoteAuthorHandle; 80 + final String? quoteText; 78 81 final int? draftId; 79 82 final String? initialText; 80 83 final String? editPostUri;
+383 -26
lib/features/compose/presentation/compose_screen.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 import 'dart:io'; 3 4 import 'dart:ui' as ui; ··· 9 10 import 'package:image_picker/image_picker.dart'; 10 11 import 'package:intl/intl.dart'; 11 12 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 13 + import 'package:lazurite/features/compose/data/link_preview_service.dart'; 12 14 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 13 15 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 16 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 17 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 14 18 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 15 19 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; 16 21 17 22 class ComposeScreen extends StatefulWidget { 18 23 const ComposeScreen({ ··· 25 30 this.quoteUri, 26 31 this.quoteCid, 27 32 this.quoteAuthorHandle, 33 + this.quoteText, 28 34 this.draftId, 29 35 this.initialText, 30 36 this.editPostUri, 31 37 this.editPostCid, 32 38 this.editRecord, 39 + this.typeaheadRepository, 40 + this.linkPreviewService, 33 41 }); 34 42 35 43 final String? replyParentUri; ··· 40 48 final String? quoteUri; 41 49 final String? quoteCid; 42 50 final String? quoteAuthorHandle; 51 + final String? quoteText; 43 52 final int? draftId; 44 53 final String? initialText; 45 54 final String? editPostUri; 46 55 final String? editPostCid; 47 56 final Map<String, dynamic>? editRecord; 57 + final TypeaheadRepository? typeaheadRepository; 58 + final LinkPreviewService? linkPreviewService; 48 59 49 60 @override 50 61 State<ComposeScreen> createState() => _ComposeScreenState(); ··· 52 63 53 64 class _ComposeScreenState extends State<ComposeScreen> { 54 65 late final _FacetHighlightController _textController; 66 + final FocusNode _textFocusNode = FocusNode(); 55 67 final ImagePicker _imagePicker = ImagePicker(); 68 + Timer? _mentionDebounce; 69 + Timer? _linkPreviewDebounce; 70 + int _mentionQueryStart = -1; 71 + int _mentionQueryEnd = -1; 72 + int _mentionSearchGeneration = 0; 73 + int _linkPreviewGeneration = 0; 74 + List<TypeaheadResult> _mentionSuggestions = const []; 75 + bool _isSearchingMentions = false; 76 + TypeaheadRepository? _typeaheadRepository; 77 + late final LinkPreviewService _linkPreviewService; 78 + LinkPreviewData? _linkPreview; 79 + bool _isLoadingLinkPreview = false; 80 + String? _hiddenPreviewUrl; 56 81 bool _showDrafts = false; 57 82 58 83 @override ··· 60 85 super.initState(); 61 86 final isEditing = widget.editPostUri != null && widget.editPostCid != null && widget.editRecord != null; 62 87 _textController = _FacetHighlightController(); 88 + _typeaheadRepository = widget.typeaheadRepository; 89 + if (_typeaheadRepository == null) { 90 + try { 91 + _typeaheadRepository = context.read<TypeaheadRepository>(); 92 + } catch (_) { 93 + _typeaheadRepository = null; 94 + } 95 + } 96 + _linkPreviewService = widget.linkPreviewService ?? LinkPreviewService(); 97 + _textFocusNode.addListener(() { 98 + if (!_textFocusNode.hasFocus) { 99 + _clearMentionSuggestions(); 100 + } 101 + }); 63 102 if (widget.initialText?.isNotEmpty ?? false) { 64 103 _textController.text = widget.initialText!; 65 104 } ··· 104 143 void dispose() { 105 144 _textController.removeListener(_onTextChanged); 106 145 _textController.dispose(); 146 + _textFocusNode.dispose(); 147 + _mentionDebounce?.cancel(); 148 + _linkPreviewDebounce?.cancel(); 107 149 super.dispose(); 108 150 } 109 151 110 152 void _onTextChanged() { 111 153 final bloc = context.read<ComposeBloc>(); 112 154 final text = _textController.text; 155 + _scheduleMentionLookup(text); 156 + _scheduleLinkPreviewLookup(text); 113 157 if (bloc.state.text == text) { 114 158 return; 115 159 } 116 160 bloc.add(TextChanged(text)); 117 161 } 118 162 163 + void _scheduleMentionLookup(String text) { 164 + final repository = _typeaheadRepository; 165 + if (repository == null || !_textFocusNode.hasFocus) { 166 + _clearMentionSuggestions(); 167 + return; 168 + } 169 + 170 + final activeMention = _activeMentionQuery(text, _textController.selection); 171 + if (activeMention == null || activeMention.query.length < 2) { 172 + _clearMentionSuggestions(); 173 + return; 174 + } 175 + 176 + _mentionQueryStart = activeMention.start; 177 + _mentionQueryEnd = activeMention.end; 178 + _mentionDebounce?.cancel(); 179 + _mentionDebounce = Timer(const Duration(milliseconds: 220), () async { 180 + final generation = ++_mentionSearchGeneration; 181 + if (!mounted) { 182 + return; 183 + } 184 + setState(() => _isSearchingMentions = true); 185 + try { 186 + final results = await repository.search(query: activeMention.query, limit: 6); 187 + if (!mounted || generation != _mentionSearchGeneration) { 188 + return; 189 + } 190 + setState(() { 191 + _mentionSuggestions = results; 192 + _isSearchingMentions = false; 193 + }); 194 + } catch (_) { 195 + if (!mounted || generation != _mentionSearchGeneration) { 196 + return; 197 + } 198 + setState(() { 199 + _mentionSuggestions = const []; 200 + _isSearchingMentions = false; 201 + }); 202 + } 203 + }); 204 + } 205 + 206 + void _scheduleLinkPreviewLookup(String text) { 207 + if (context.read<ComposeBloc>().state.isEditing) { 208 + _clearLinkPreview(); 209 + return; 210 + } 211 + 212 + if (_hiddenPreviewUrl != null && !text.contains(_hiddenPreviewUrl!)) { 213 + _hiddenPreviewUrl = null; 214 + } 215 + 216 + final firstLink = LinkPreviewService.firstLink(text); 217 + if (firstLink == null) { 218 + _clearLinkPreview(); 219 + return; 220 + } 221 + 222 + if (_hiddenPreviewUrl != null && _hiddenPreviewUrl == firstLink) { 223 + _clearLinkPreview(); 224 + return; 225 + } 226 + 227 + if (_linkPreview?.uri == firstLink) { 228 + return; 229 + } 230 + 231 + _linkPreviewDebounce?.cancel(); 232 + _linkPreviewDebounce = Timer(const Duration(milliseconds: 320), () async { 233 + final generation = ++_linkPreviewGeneration; 234 + if (!mounted) { 235 + return; 236 + } 237 + setState(() => _isLoadingLinkPreview = true); 238 + try { 239 + final preview = await _linkPreviewService.fetch(firstLink); 240 + if (!mounted || generation != _linkPreviewGeneration) { 241 + return; 242 + } 243 + setState(() { 244 + _linkPreview = preview; 245 + _isLoadingLinkPreview = false; 246 + }); 247 + } catch (_) { 248 + if (!mounted || generation != _linkPreviewGeneration) { 249 + return; 250 + } 251 + setState(() { 252 + _linkPreview = null; 253 + _isLoadingLinkPreview = false; 254 + }); 255 + } 256 + }); 257 + } 258 + 259 + void _clearMentionSuggestions() { 260 + _mentionDebounce?.cancel(); 261 + _mentionQueryStart = -1; 262 + _mentionQueryEnd = -1; 263 + _mentionSearchGeneration++; 264 + if (_mentionSuggestions.isNotEmpty || _isSearchingMentions) { 265 + setState(() { 266 + _mentionSuggestions = const []; 267 + _isSearchingMentions = false; 268 + }); 269 + } 270 + } 271 + 272 + void _clearLinkPreview() { 273 + _linkPreviewDebounce?.cancel(); 274 + _linkPreviewGeneration++; 275 + if (_linkPreview != null || _isLoadingLinkPreview) { 276 + setState(() { 277 + _linkPreview = null; 278 + _isLoadingLinkPreview = false; 279 + }); 280 + } 281 + } 282 + 283 + void _applyMention(TypeaheadResult result) { 284 + final start = _mentionQueryStart; 285 + final end = _mentionQueryEnd; 286 + if (start < 0 || end < start || end > _textController.text.length) { 287 + return; 288 + } 289 + 290 + final currentText = _textController.text; 291 + final replacement = '@${result.handle} '; 292 + final nextText = '${currentText.substring(0, start)}$replacement${currentText.substring(end)}'; 293 + final cursorOffset = start + replacement.length; 294 + _textController.value = TextEditingValue( 295 + text: nextText, 296 + selection: TextSelection.collapsed(offset: cursorOffset), 297 + ); 298 + _clearMentionSuggestions(); 299 + } 300 + 301 + ({int start, int end, String query})? _activeMentionQuery(String text, TextSelection selection) { 302 + if (!selection.isValid || !selection.isCollapsed) { 303 + return null; 304 + } 305 + 306 + final cursor = selection.baseOffset; 307 + if (cursor <= 0 || cursor > text.length) { 308 + return null; 309 + } 310 + 311 + final left = text.substring(0, cursor); 312 + final mentionStart = left.lastIndexOf('@'); 313 + if (mentionStart < 0) { 314 + return null; 315 + } 316 + 317 + if (mentionStart > 0) { 318 + final prefixChar = text[mentionStart - 1]; 319 + const allowedPrefix = '([{"\''; 320 + if (!RegExp(r'\s').hasMatch(prefixChar) && !allowedPrefix.contains(prefixChar)) { 321 + return null; 322 + } 323 + } 324 + 325 + final candidate = text.substring(mentionStart + 1, cursor); 326 + if (candidate.isEmpty || candidate.contains(RegExp(r'\s'))) { 327 + return null; 328 + } 329 + 330 + final suffix = cursor < text.length ? text[cursor] : ''; 331 + if (suffix.isNotEmpty && RegExp(r'[A-Za-z0-9._-]').hasMatch(suffix)) { 332 + return null; 333 + } 334 + 335 + return (start: mentionStart, end: cursor, query: candidate); 336 + } 337 + 119 338 Future<void> _pickImage() async { 120 339 final state = context.read<ComposeBloc>().state; 121 340 if (!state.canAddMoreMedia) { ··· 423 642 ); 424 643 } 425 644 645 + Widget _buildMentionAutocompletePanel() { 646 + if (!_textFocusNode.hasFocus) { 647 + return const SizedBox.shrink(); 648 + } 649 + if (!_isSearchingMentions && _mentionSuggestions.isEmpty) { 650 + return const SizedBox.shrink(); 651 + } 652 + 653 + return Container( 654 + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), 655 + decoration: BoxDecoration( 656 + color: theme.colorScheme.surfaceContainerLow, 657 + borderRadius: BorderRadius.circular(12), 658 + border: Border.all(color: theme.colorScheme.outlineVariant), 659 + ), 660 + constraints: const BoxConstraints(maxHeight: 220), 661 + child: _isSearchingMentions 662 + ? const Padding( 663 + padding: EdgeInsets.symmetric(vertical: 16), 664 + child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), 665 + ) 666 + : ListView.separated( 667 + shrinkWrap: true, 668 + itemCount: _mentionSuggestions.length, 669 + separatorBuilder: (_, _) => Divider(height: 1, color: theme.colorScheme.outlineVariant), 670 + itemBuilder: (context, index) { 671 + final actor = _mentionSuggestions[index]; 672 + return ListTile( 673 + dense: true, 674 + leading: CircleAvatar( 675 + radius: 14, 676 + backgroundImage: actor.avatarUrl != null ? NetworkImage(actor.avatarUrl!) : null, 677 + child: actor.avatarUrl == null 678 + ? Text((actor.displayName ?? actor.handle).substring(0, 1).toUpperCase()) 679 + : null, 680 + ), 681 + title: Text( 682 + actor.displayName ?? actor.handle, 683 + maxLines: 1, 684 + overflow: TextOverflow.ellipsis, 685 + style: theme.textTheme.bodyMedium, 686 + ), 687 + subtitle: Text('@${actor.handle}', style: theme.textTheme.bodySmall), 688 + onTap: () => _applyMention(actor), 689 + ); 690 + }, 691 + ), 692 + ); 693 + } 694 + 695 + Widget _buildQuotePreview(ComposeState state) { 696 + if (!state.isQuote) { 697 + return const SizedBox.shrink(); 698 + } 699 + 700 + final quotedHandle = widget.quoteAuthorHandle?.trim(); 701 + final quotedText = widget.quoteText?.trim() ?? ''; 702 + 703 + return Container( 704 + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), 705 + padding: const EdgeInsets.all(12), 706 + decoration: BoxDecoration( 707 + color: theme.colorScheme.surfaceContainerLow, 708 + borderRadius: BorderRadius.circular(12), 709 + border: Border.all(color: theme.colorScheme.outlineVariant), 710 + ), 711 + child: Row( 712 + crossAxisAlignment: CrossAxisAlignment.start, 713 + children: [ 714 + Icon(Icons.format_quote, size: 18, color: theme.colorScheme.onSurfaceVariant), 715 + const SizedBox(width: 10), 716 + Expanded( 717 + child: Column( 718 + crossAxisAlignment: CrossAxisAlignment.start, 719 + children: [ 720 + Text( 721 + quotedHandle != null && quotedHandle.isNotEmpty ? 'Quoting @$quotedHandle' : 'Quoting post', 722 + style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), 723 + ), 724 + if (quotedText.isNotEmpty) ...[ 725 + const SizedBox(height: 4), 726 + Text( 727 + quotedText, 728 + maxLines: 3, 729 + overflow: TextOverflow.ellipsis, 730 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 731 + ), 732 + ], 733 + ], 734 + ), 735 + ), 736 + IconButton( 737 + onPressed: () => context.read<ComposeBloc>().add(const QuoteContextCleared()), 738 + icon: const Icon(Icons.close), 739 + visualDensity: VisualDensity.compact, 740 + tooltip: 'Remove quoted post', 741 + ), 742 + ], 743 + ), 744 + ); 745 + } 746 + 747 + Widget _buildComposerLinkPreview(ComposeState state) { 748 + if (_linkPreview == null || state.isQuote || state.hasMedia || state.hasVideo || state.isEditing) { 749 + return const SizedBox.shrink(); 750 + } 751 + 752 + return Padding( 753 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 754 + child: ExternalLinkPreviewCard( 755 + uri: _linkPreview!.uri, 756 + title: _linkPreview!.title, 757 + description: _linkPreview!.description, 758 + thumbUrl: _linkPreview!.thumbnailUrl, 759 + compact: true, 760 + onRemove: () { 761 + setState(() { 762 + _hiddenPreviewUrl = _linkPreview!.uri; 763 + _linkPreview = null; 764 + }); 765 + }, 766 + ), 767 + ); 768 + } 769 + 426 770 ThemeData get theme => Theme.of(context); 427 771 428 772 void _submitPost() { 429 - context.read<ComposeBloc>().add(const PostSubmitted()); 773 + context.read<ComposeBloc>().add(PostSubmitted(suppressedLinkUri: _hiddenPreviewUrl)); 430 774 } 431 775 432 776 void _saveDraft() { ··· 599 943 } 600 944 601 945 if (!state.isReply || widget.replyAuthorHandle == null) { 602 - return const SizedBox.shrink(); 946 + return _buildQuotePreview(state); 603 947 } 604 - return Container( 605 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 606 - decoration: BoxDecoration( 607 - color: theme.colorScheme.surfaceContainerHighest, 608 - border: Border(bottom: BorderSide(color: theme.dividerColor)), 609 - ), 610 - child: Row( 611 - children: [ 612 - Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), 613 - const SizedBox(width: 8), 614 - Text( 615 - 'Replying to ', 616 - style: Theme.of( 617 - context, 618 - ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 948 + return Column( 949 + children: [ 950 + Container( 951 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 952 + decoration: BoxDecoration( 953 + color: theme.colorScheme.surfaceContainerHighest, 954 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 619 955 ), 620 - Text( 621 - '@${widget.replyAuthorHandle}', 622 - style: theme.textTheme.bodySmall?.copyWith( 623 - color: theme.colorScheme.primary, 624 - fontWeight: FontWeight.w500, 625 - ), 956 + child: Row( 957 + children: [ 958 + Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), 959 + const SizedBox(width: 8), 960 + Text( 961 + 'Replying to ', 962 + style: Theme.of( 963 + context, 964 + ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 965 + ), 966 + Text( 967 + '@${widget.replyAuthorHandle}', 968 + style: theme.textTheme.bodySmall?.copyWith( 969 + color: theme.colorScheme.primary, 970 + fontWeight: FontWeight.w500, 971 + ), 972 + ), 973 + ], 626 974 ), 627 - ], 628 - ), 975 + ), 976 + _buildQuotePreview(state), 977 + ], 629 978 ); 630 979 }, 631 980 ), ··· 634 983 padding: const EdgeInsets.all(16), 635 984 child: TextField( 636 985 controller: _textController, 986 + focusNode: _textFocusNode, 637 987 maxLines: null, 638 988 expands: true, 639 989 textAlignVertical: TextAlignVertical.top, ··· 642 992 border: InputBorder.none, 643 993 contentPadding: EdgeInsets.zero, 644 994 ), 645 - style: theme.textTheme.bodyLarge?.copyWith(height: 1.5), 995 + style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 17), 646 996 ), 647 997 ), 648 998 ), 999 + _buildMentionAutocompletePanel(), 1000 + BlocBuilder<ComposeBloc, ComposeState>(builder: (context, state) => _buildComposerLinkPreview(state)), 1001 + if (_isLoadingLinkPreview) 1002 + const Padding( 1003 + padding: EdgeInsets.only(bottom: 8), 1004 + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), 1005 + ), 649 1006 BlocBuilder<ComposeBloc, ComposeState>( 650 1007 builder: (context, state) { 651 1008 if (!state.hasScheduledTime) return const SizedBox.shrink();
+6 -1
lib/features/feed/presentation/post_thread_screen.dart
··· 908 908 909 909 context.push( 910 910 '/compose', 911 - extra: ComposeRouteArgs(quoteUri: post.uri.toString(), quoteCid: post.cid, quoteAuthorHandle: post.author.handle), 911 + extra: ComposeRouteArgs( 912 + quoteUri: post.uri.toString(), 913 + quoteCid: post.cid, 914 + quoteAuthorHandle: post.author.handle, 915 + quoteText: _editableTextFromRecord(post.record), 916 + ), 912 917 ); 913 918 } 914 919
+17 -1
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 2 2 import 'dart:convert'; 3 3 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_post.dart'; 5 6 import 'package:bluesky/moderation.dart' as bsky_moderation; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 291 292 void _onQuote(BuildContext context) { 292 293 HapticHelper.selectionClick(); 293 294 final post = feedViewPost.post; 295 + final quotedText = _recordText(post.record); 294 296 context.push( 295 297 '/compose', 296 - extra: ComposeRouteArgs(quoteUri: post.uri.toString(), quoteCid: post.cid, quoteAuthorHandle: post.author.handle), 298 + extra: ComposeRouteArgs( 299 + quoteUri: post.uri.toString(), 300 + quoteCid: post.cid, 301 + quoteAuthorHandle: post.author.handle, 302 + quoteText: quotedText, 303 + ), 297 304 ); 298 305 } 299 306 ··· 339 346 confirmDestructive: true, 340 347 onConfirmed: () => context.read<PostActionCubit>().deletePost(), 341 348 ); 349 + } 350 + 351 + String _recordText(Map<String, dynamic> record) { 352 + try { 353 + return FeedPostRecord.fromJson(record).text; 354 + } catch (_) { 355 + final text = record['text']; 356 + return text is String ? text : ''; 357 + } 342 358 } 343 359 }
+10 -91
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 10 10 import 'package:flutter/material.dart'; 11 11 import 'package:go_router/go_router.dart'; 12 12 import 'package:lazurite/core/cache/lazurite_image_cache.dart'; 13 - import 'package:lazurite/core/router/in_app_link_resolver.dart'; 13 + import 'package:lazurite/core/theme/theme_extensions.dart'; 14 14 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 15 15 import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 16 16 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; ··· 19 19 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 20 20 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 21 21 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 22 + import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; 22 23 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 23 - import 'package:url_launcher/url_launcher.dart'; 24 - import 'package:lazurite/core/theme/theme_extensions.dart'; 25 24 26 25 /// Renders the appropriate embed widget for a post embed. 27 26 /// ··· 137 136 ); 138 137 } 139 138 140 - Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { 141 - final theme = Theme.of(context); 142 - final colorScheme = theme.colorScheme; 143 - 144 - return InkWell( 145 - onTap: () => _openExternalUri(context, external.uri), 146 - child: Container( 147 - clipBehavior: Clip.antiAlias, 148 - decoration: BoxDecoration( 149 - border: Border.all(color: colorScheme.outlineVariant), 150 - borderRadius: BorderRadius.circular(12), 151 - color: colorScheme.surfaceContainerLow, 152 - ), 153 - child: Column( 154 - crossAxisAlignment: CrossAxisAlignment.start, 155 - children: [ 156 - if (external.thumb != null) 157 - CachedNetworkImage( 158 - imageUrl: external.thumb!, 159 - cacheManager: LazuriteImageCacheManager.instance, 160 - height: compact ? 140 : 180, 161 - width: double.infinity, 162 - fit: BoxFit.cover, 163 - errorWidget: (_, _, _) => const SizedBox(height: 0), 164 - ), 165 - Padding( 166 - padding: const EdgeInsets.all(12), 167 - child: Column( 168 - crossAxisAlignment: CrossAxisAlignment.start, 169 - children: [ 170 - Text( 171 - external.title, 172 - style: theme.textTheme.bodyLarge?.copyWith( 173 - fontWeight: FontWeight.w700, 174 - color: colorScheme.onSurface, 175 - ), 176 - ), 177 - if (external.description.isNotEmpty) ...[ 178 - const SizedBox(height: 4), 179 - Text( 180 - external.description, 181 - maxLines: compact ? 3 : null, 182 - overflow: compact ? TextOverflow.ellipsis : TextOverflow.visible, 183 - style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant, height: 1.4), 184 - ), 185 - ], 186 - const SizedBox(height: 8), 187 - Text( 188 - _displayHost(external.uri), 189 - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 190 - ), 191 - ], 192 - ), 193 - ), 194 - ], 195 - ), 196 - ), 197 - ); 198 - } 139 + Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) => ExternalLinkPreviewCard( 140 + uri: external.uri, 141 + title: external.title, 142 + description: external.description, 143 + thumbUrl: external.thumb, 144 + compact: compact, 145 + cacheManager: LazuriteImageCacheManager.instance, 146 + ); 199 147 200 148 Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 201 149 final moderationService = maybeModerationService(context); ··· 447 395 return segment.isEmpty ? 'image.jpg' : segment; 448 396 } 449 397 450 - String _displayHost(String rawUri) { 451 - final uri = Uri.tryParse(rawUri); 452 - final host = uri?.host.trim(); 453 - if (host == null || host.isEmpty) { 454 - return rawUri; 455 - } 456 - return host; 457 - } 458 - 459 - void _openExternalUri(BuildContext context, String rawUri) { 460 - final inAppRoute = InAppLinkResolver.resolveRoute(rawUri); 461 - final router = GoRouter.maybeOf(context); 462 - if (inAppRoute != null && router != null) { 463 - router.push(inAppRoute); 464 - return; 465 - } 466 - 467 - final uri = Uri.tryParse(rawUri); 468 - if (uri == null) { 469 - return; 470 - } 471 - 472 - _launchExternal(uri); 473 - } 474 - 475 398 FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 476 399 try { 477 400 return FeedPostRecord.fromJson(record); ··· 479 402 return null; 480 403 } 481 404 } 482 - } 483 - 484 - Future<void> _launchExternal(Uri url) async { 485 - await launchUrl(url, mode: LaunchMode.externalApplication); 486 405 } 487 406 488 407 enum _ImageThumbnailAction { save, share }
+2 -1
lib/features/profile/presentation/profile_screen.dart
··· 24 24 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 25 25 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 26 26 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 27 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 27 28 import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; 28 29 import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 29 30 import 'package:lazurite/features/lists/data/list_repository.dart'; ··· 762 763 763 764 ConstrainedBox( 764 765 constraints: const BoxConstraints(maxWidth: 500), 765 - child: Text(profile.description!, style: textTheme.bodyMedium), 766 + child: FacetText(text: profile.description!, style: textTheme.bodyMedium), 766 767 ), 767 768 ], 768 769 if (metaChildren.isNotEmpty) ...[
+14
test/core/router/compose_route_extra_parser_test.dart
··· 28 28 expect(parsed.replyAuthorHandle, 'alice.bsky.social'); 29 29 }); 30 30 31 + test('parses quote metadata from map payload', () { 32 + final parsed = ComposeRouteArgs.parseExtra({ 33 + 'quoteUri': 'at://did:plc:test/app.bsky.feed.post/quote', 34 + 'quoteCid': 'cid-quote', 35 + 'quoteAuthorHandle': 'alice.bsky.social', 36 + 'quoteText': 'quoted post text', 37 + }); 38 + 39 + expect(parsed.quoteUri, 'at://did:plc:test/app.bsky.feed.post/quote'); 40 + expect(parsed.quoteCid, 'cid-quote'); 41 + expect(parsed.quoteAuthorHandle, 'alice.bsky.social'); 42 + expect(parsed.quoteText, 'quoted post text'); 43 + }); 44 + 31 45 test('parses edit context fields from map payload', () { 32 46 final parsed = ComposeRouteArgs.parseExtra({ 33 47 'initialText': 'updated post',
+205
test/features/compose/bloc/compose_bloc_test.dart
··· 44 44 setUp(() { 45 45 mockDatabase = MockAppDatabase(); 46 46 mockRepository = MockComposeRepository(); 47 + when(() => mockRepository.fetchLinkPreview(any())).thenAnswer((_) async => null); 48 + when(() => mockRepository.buildExternalEmbedFromLink(any())).thenAnswer((_) async => null); 49 + when( 50 + () => mockRepository.resolveReplyReferences( 51 + parentUri: any(named: 'parentUri'), 52 + parentCid: any(named: 'parentCid'), 53 + fallbackRootUri: any(named: 'fallbackRootUri'), 54 + fallbackRootCid: any(named: 'fallbackRootCid'), 55 + ), 56 + ).thenAnswer((_) async => null); 47 57 composeBloc = ComposeBloc(composeRepository: mockRepository, database: mockDatabase, accountDid: 'did:plc:test'); 48 58 registerFallbackValue(FakeDraftsCompanion()); 49 59 }); ··· 653 663 repo: 'did:plc:test', 654 664 ), 655 665 ).called(1); 666 + }, 667 + ); 668 + 669 + blocTest<ComposeBloc, ComposeState>( 670 + 'builds Bluesky external embed from detected link preview metadata', 671 + build: () { 672 + when(() => mockRepository.buildExternalEmbedFromLink('https://example.com/article')).thenAnswer( 673 + (_) async => { 674 + r'$type': 'app.bsky.embed.external', 675 + 'external': { 676 + 'uri': 'https://example.com/article', 677 + 'title': 'Example Article', 678 + 'description': 'Example description', 679 + }, 680 + }, 681 + ); 682 + when( 683 + () => mockRepository.createPost( 684 + text: any(named: 'text'), 685 + facets: any(named: 'facets'), 686 + embed: any(named: 'embed'), 687 + reply: any(named: 'reply'), 688 + repo: any(named: 'repo'), 689 + ), 690 + ).thenAnswer((_) async => true); 691 + return composeBloc; 692 + }, 693 + seed: () => 694 + const ComposeState.ready(text: 'Read this https://example.com/article', graphemeCount: 37, isEmpty: false), 695 + act: (bloc) => bloc.add(const PostSubmitted()), 696 + verify: (_) { 697 + final embed = 698 + verify( 699 + () => mockRepository.createPost( 700 + text: any(named: 'text'), 701 + facets: any(named: 'facets'), 702 + embed: captureAny(named: 'embed'), 703 + reply: any(named: 'reply'), 704 + repo: any(named: 'repo'), 705 + ), 706 + ).captured.single 707 + as Map<String, dynamic>?; 708 + 709 + expect(embed, isNotNull); 710 + expect(embed![r'$type'], 'app.bsky.embed.external'); 711 + final external = embed['external'] as Map<String, dynamic>; 712 + expect(external['uri'], 'https://example.com/article'); 713 + expect(external['title'], 'Example Article'); 714 + expect(external['description'], 'Example description'); 715 + }, 716 + ); 717 + 718 + blocTest<ComposeBloc, ComposeState>( 719 + 'does not build external embed when link is explicitly suppressed', 720 + build: () { 721 + when( 722 + () => mockRepository.createPost( 723 + text: any(named: 'text'), 724 + facets: any(named: 'facets'), 725 + embed: any(named: 'embed'), 726 + reply: any(named: 'reply'), 727 + repo: any(named: 'repo'), 728 + ), 729 + ).thenAnswer((_) async => true); 730 + return composeBloc; 731 + }, 732 + seed: () => 733 + const ComposeState.ready(text: 'Read this https://example.com/article', graphemeCount: 37, isEmpty: false), 734 + act: (bloc) => bloc.add(const PostSubmitted(suppressedLinkUri: 'https://example.com/article')), 735 + verify: (_) { 736 + verifyNever(() => mockRepository.buildExternalEmbedFromLink(any())); 737 + final embed = verify( 738 + () => mockRepository.createPost( 739 + text: any(named: 'text'), 740 + facets: any(named: 'facets'), 741 + embed: captureAny(named: 'embed'), 742 + reply: any(named: 'reply'), 743 + repo: any(named: 'repo'), 744 + ), 745 + ).captured.single; 746 + expect(embed, isNull); 747 + }, 748 + ); 749 + 750 + blocTest<ComposeBloc, ComposeState>( 751 + 'builds recordWithMedia when quoting and link preview embed both exist', 752 + build: () { 753 + when(() => mockRepository.buildExternalEmbedFromLink('https://example.com/article')).thenAnswer( 754 + (_) async => { 755 + r'$type': 'app.bsky.embed.external', 756 + 'external': { 757 + 'uri': 'https://example.com/article', 758 + 'title': 'Example Article', 759 + 'description': 'Example description', 760 + }, 761 + }, 762 + ); 763 + when( 764 + () => mockRepository.createPost( 765 + text: any(named: 'text'), 766 + facets: any(named: 'facets'), 767 + embed: any(named: 'embed'), 768 + reply: any(named: 'reply'), 769 + repo: any(named: 'repo'), 770 + ), 771 + ).thenAnswer((_) async => true); 772 + return composeBloc; 773 + }, 774 + seed: () => const ComposeState.ready( 775 + text: 'Quote this https://example.com/article', 776 + graphemeCount: 36, 777 + isEmpty: false, 778 + quoteUri: 'at://did:plc:test/app.bsky.feed.post/quote', 779 + quoteCid: 'cid-quote', 780 + ), 781 + act: (bloc) => bloc.add(const PostSubmitted()), 782 + verify: (_) { 783 + final embed = 784 + verify( 785 + () => mockRepository.createPost( 786 + text: any(named: 'text'), 787 + facets: any(named: 'facets'), 788 + embed: captureAny(named: 'embed'), 789 + reply: any(named: 'reply'), 790 + repo: any(named: 'repo'), 791 + ), 792 + ).captured.single 793 + as Map<String, dynamic>?; 794 + 795 + expect(embed, isNotNull); 796 + expect(embed![r'$type'], 'app.bsky.embed.recordWithMedia'); 797 + final record = embed['record'] as Map<String, dynamic>; 798 + expect(record[r'$type'], 'app.bsky.embed.record'); 799 + final media = embed['media'] as Map<String, dynamic>; 800 + expect(media[r'$type'], 'app.bsky.embed.external'); 801 + }, 802 + ); 803 + 804 + blocTest<ComposeBloc, ComposeState>( 805 + 'resolves latest reply parent/root references before posting reply', 806 + build: () { 807 + when( 808 + () => mockRepository.resolveReplyReferences( 809 + parentUri: any(named: 'parentUri'), 810 + parentCid: any(named: 'parentCid'), 811 + fallbackRootUri: any(named: 'fallbackRootUri'), 812 + fallbackRootCid: any(named: 'fallbackRootCid'), 813 + ), 814 + ).thenAnswer( 815 + (_) async => ( 816 + parentCid: 'cid-parent-latest', 817 + rootUri: 'at://did:plc:test/app.bsky.feed.post/root-latest', 818 + rootCid: 'cid-root-latest', 819 + ), 820 + ); 821 + when( 822 + () => mockRepository.createPost( 823 + text: any(named: 'text'), 824 + facets: any(named: 'facets'), 825 + embed: any(named: 'embed'), 826 + reply: any(named: 'reply'), 827 + repo: any(named: 'repo'), 828 + ), 829 + ).thenAnswer((_) async => true); 830 + return composeBloc; 831 + }, 832 + seed: () => const ComposeState.ready( 833 + text: 'reply text', 834 + graphemeCount: 10, 835 + isEmpty: false, 836 + replyParentUri: 'at://did:plc:test/app.bsky.feed.post/parent', 837 + replyParentCid: 'cid-parent-old', 838 + replyRootUri: 'at://did:plc:test/app.bsky.feed.post/root-old', 839 + replyRootCid: 'cid-root-old', 840 + ), 841 + act: (bloc) => bloc.add(const PostSubmitted()), 842 + verify: (_) { 843 + final reply = 844 + verify( 845 + () => mockRepository.createPost( 846 + text: any(named: 'text'), 847 + facets: any(named: 'facets'), 848 + embed: any(named: 'embed'), 849 + reply: captureAny(named: 'reply'), 850 + repo: any(named: 'repo'), 851 + ), 852 + ).captured.single 853 + as Map<String, dynamic>?; 854 + 855 + expect(reply, isNotNull); 856 + final parent = reply!['parent'] as Map<String, dynamic>; 857 + final root = reply['root'] as Map<String, dynamic>; 858 + expect(parent['cid'], 'cid-parent-latest'); 859 + expect(root['uri'], 'at://did:plc:test/app.bsky.feed.post/root-latest'); 860 + expect(root['cid'], 'cid-root-latest'); 656 861 }, 657 862 ); 658 863
+45
test/features/compose/presentation/compose_screen_test.dart
··· 217 217 }); 218 218 }); 219 219 220 + group('quote preview', () { 221 + testWidgets('shows quote preview with author and text when quote context exists', (tester) async { 222 + seedState( 223 + const ComposeState.ready(quoteUri: 'at://did:plc:test/app.bsky.feed.post/quoted', quoteCid: 'quoted-cid'), 224 + ); 225 + 226 + await tester.pumpWidget( 227 + buildSubject( 228 + screen: const ComposeScreen( 229 + quoteUri: 'at://did:plc:test/app.bsky.feed.post/quoted', 230 + quoteCid: 'quoted-cid', 231 + quoteAuthorHandle: 'alice.bsky.social', 232 + quoteText: 'Quoted post body', 233 + ), 234 + ), 235 + ); 236 + await tester.pump(); 237 + 238 + expect(find.text('Quoting @alice.bsky.social'), findsOneWidget); 239 + expect(find.text('Quoted post body'), findsOneWidget); 240 + }); 241 + 242 + testWidgets('remove button clears quote context', (tester) async { 243 + seedState( 244 + const ComposeState.ready(quoteUri: 'at://did:plc:test/app.bsky.feed.post/quoted', quoteCid: 'quoted-cid'), 245 + ); 246 + 247 + await tester.pumpWidget( 248 + buildSubject( 249 + screen: const ComposeScreen( 250 + quoteUri: 'at://did:plc:test/app.bsky.feed.post/quoted', 251 + quoteCid: 'quoted-cid', 252 + quoteAuthorHandle: 'alice.bsky.social', 253 + ), 254 + ), 255 + ); 256 + await tester.pump(); 257 + 258 + await tester.tap(find.byTooltip('Remove quoted post')); 259 + await tester.pump(); 260 + 261 + verify(() => mockBloc.add(const QuoteContextCleared())).called(1); 262 + }); 263 + }); 264 + 220 265 group('inline drafts panel (Bug #3)', () { 221 266 testWidgets('drafts panel is hidden initially', (tester) async { 222 267 seedState(const ComposeState.ready());
+14 -12
test/features/profile/presentation/profile_screen_test.dart
··· 162 162 163 163 expect(find.text('RIVER TAM'), findsOneWidget); 164 164 expect(find.text('@me.bsky.social'), findsOneWidget); 165 - expect(find.text('Signal and signal boost.'), findsOneWidget); 165 + expect( 166 + find.byWidgetPredicate( 167 + (widget) => widget is RichText && widget.text.toPlainText().contains('Signal and signal boost.'), 168 + ), 169 + findsOneWidget, 170 + ); 166 171 expect(find.text('she/her'), findsOneWidget); 167 172 expect(find.text('river.example'), findsOneWidget); 168 173 expect(find.text('Joined March 2024'), findsOneWidget); ··· 415 420 testWidgets('bio is shown', (tester) async { 416 421 useLargeScreen(tester); 417 422 await tester.pumpWidget(buildSubject()); 418 - expect(find.text('Signal and signal boost.'), findsOneWidget); 423 + expect( 424 + find.byWidgetPredicate( 425 + (widget) => widget is RichText && widget.text.toPlainText().contains('Signal and signal boost.'), 426 + ), 427 + findsOneWidget, 428 + ); 419 429 }); 420 430 421 431 testWidgets('stats row is rendered in a bordered container', (tester) async { ··· 586 596 final parentPost = PostView( 587 597 uri: AtUri('at://did:plc:parent/app.bsky.feed.post/parent-$id'), 588 598 cid: 'cid-parent-$id', 589 - author: const ProfileViewBasic( 590 - did: 'did:plc:parent', 591 - handle: 'parent.bsky.social', 592 - displayName: 'Parent User', 593 - ), 599 + author: const ProfileViewBasic(did: 'did:plc:parent', handle: 'parent.bsky.social', displayName: 'Parent User'), 594 600 record: parentRecord.toJson(), 595 601 indexedAt: DateTime.utc(2026, 3, 1), 596 602 ); 597 - final replyRecord = FeedPostRecord( 598 - text: 'Reply $id', 599 - createdAt: DateTime.utc(2026, 3, 1, 0, 5), 600 - reply: null, 601 - ); 603 + final replyRecord = FeedPostRecord(text: 'Reply $id', createdAt: DateTime.utc(2026, 3, 1, 0, 5), reply: null); 602 604 603 605 return FeedViewPost( 604 606 post: PostView(