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: delete-recreate with rkey preservation for editing posts

+1231 -74
+101
docs/specs/post-editing.md
··· 1 + --- 2 + title: Post Editing Spec (v1) 3 + updated: 2026-04-14 4 + --- 5 + 6 + ## Summary 7 + 8 + Add AT Protocol post editing to Lazurite by replacing post records via 9 + `com.atproto.repo.deleteRecord` + `com.atproto.repo.createRecord` (same `rkey`, 10 + same URI), with a v1 scope of: 11 + 12 + - Entry point: thread screen only 13 + - Editable fields: post text + regenerated facets 14 + - Preserved fields: reply/embed/langs/labels/tags/unknown fields 15 + - Concurrency control: `swapRecord` with the current post CID 16 + 17 + ## Protocol Mechanics 18 + 19 + ### Record Replacement 20 + 21 + Use delete + recreate on the existing `app.bsky.feed.post` rkey: 22 + 23 + - `repo`: authenticated account DID 24 + - `collection`: `app.bsky.feed.post` 25 + - `rkey`: extracted from post AT-URI 26 + - Delete guard: `swapRecord` with latest/current CID from the post view 27 + - Recreate: `createRecord` with the same `rkey` to preserve AT-URI 28 + 29 + The edit payload is built from the original record, replacing only: 30 + 31 + - `text` 32 + - `facets` (recomputed from updated text; removed when empty) 33 + 34 + `createdAt` is preserved from the original record when present. If missing or 35 + invalid, fallback to current UTC timestamp as a defensive safeguard. 36 + 37 + ### Conflict Handling 38 + 39 + When delete/recreate detects stale state (`InvalidSwap`) or changed ownership, 40 + Lazurite treats the edit as a conflict and shows a non-merge message instructing 41 + the user to reopen and retry. 42 + 43 + If recreate fails after delete, Lazurite attempts defensive recovery by 44 + restoring the original record on the same `rkey`. 45 + 46 + ## UX and Flow 47 + 48 + ### Thread Entry 49 + 50 + For author-owned posts in the thread action sheet: 51 + 52 + - Add `Edit Post` action. 53 + - Navigate to compose with edit context: 54 + - `editPostUri` 55 + - `editPostCid` 56 + - `editRecord` 57 + - `initialText` 58 + 59 + On successful edit completion, refresh the thread by reloading the current 60 + post URI. 61 + 62 + ### Compose Edit Mode 63 + 64 + Compose supports explicit edit mode via route context. 65 + 66 + Edit-mode behavior: 67 + 68 + - Title/action labels switch to edit wording (`Edit Post`, `Save Changes`) 69 + - Inline algorithm-impact notice is shown with an info dialog 70 + - Unsupported create-flow controls are disabled/hidden: 71 + - Save Draft 72 + - Schedule 73 + - Add/remove image 74 + - Add/remove video 75 + - Submission performs `putRecord` update rather than `createRecord` 76 + 77 + ## Algorithmic Implications (User Notice) 78 + 79 + Editing can change how the post is indexed and distributed: 80 + 81 + - Post metadata like `indexedAt` may change after edits 82 + - Feed ranking and search visibility may shift after re-indexing 83 + - Read-after-write propagation can be delayed across services and surfaces 84 + - Because edits are saved as delete+recreate on the same URI, counters and 85 + visibility can briefly lag while services reconcile state 86 + 87 + Lazurite informs users inline in compose edit mode and provides an info action 88 + for additional context. 89 + 90 + ## Limitations 91 + 92 + - Edit action is exposed only in thread view 93 + - Only text/facets are user-editable 94 + - No merge flow for edit conflicts 95 + 96 + ## Beyond 97 + 98 + - Add edit entry points in timeline/search/saved post cards 99 + - Consider edit-history affordances and richer conflict resolution UX 100 + - The question here is where do we store history? What happens between logins? At what 101 + point does Lazurite need its own lexicons for features like this?
+17
lib/core/router/app_router.dart
··· 81 81 return null; 82 82 } 83 83 84 + Map<String, dynamic>? readMap(String key) { 85 + final value = extra[key]; 86 + if (value is Map<String, dynamic>) { 87 + return value; 88 + } 89 + if (value is Map) { 90 + return Map<String, dynamic>.from(value); 91 + } 92 + return null; 93 + } 94 + 84 95 return ComposeRouteArgs( 85 96 replyParentUri: readString('replyParentUri'), 86 97 replyParentCid: readString('replyParentCid'), ··· 92 103 quoteAuthorHandle: readString('quoteAuthorHandle'), 93 104 draftId: readInt('draftId'), 94 105 initialText: readString('initialText'), 106 + editPostUri: readString('editPostUri'), 107 + editPostCid: readString('editPostCid'), 108 + editRecord: readMap('editRecord'), 95 109 ); 96 110 } 97 111 ··· 152 166 quoteAuthorHandle: args.quoteAuthorHandle, 153 167 draftId: args.draftId, 154 168 initialText: args.initialText, 169 + editPostUri: args.editPostUri, 170 + editPostCid: args.editPostCid, 171 + editRecord: args.editRecord, 155 172 ), 156 173 ); 157 174 },
+267 -36
lib/features/compose/bloc/compose_bloc.dart
··· 3 3 import 'dart:io'; 4 4 import 'dart:ui' as ui; 5 5 6 - import 'package:atproto_core/atproto_core.dart' show Blob, BlobRef; 6 + import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef, XRPCException; 7 7 import 'package:bluesky/bluesky.dart'; 8 8 import 'package:bluesky/app_bsky_video_defs.dart' show KnownJobStatusState; 9 9 import 'package:bluesky_text/bluesky_text.dart'; ··· 53 53 on<ReplyContextCleared>(_onReplyContextCleared); 54 54 on<QuoteContextSet>(_onQuoteContextSet); 55 55 on<QuoteContextCleared>(_onQuoteContextCleared); 56 + on<EditContextSet>(_onEditContextSet); 56 57 } 57 58 58 59 final ComposeRepository _composeRepository; ··· 342 343 } 343 344 344 345 Future<void> _onPostScheduled(PostScheduled event, Emitter<ComposeState> emit) async { 346 + if (state.isEditing) return; 345 347 emit(state.copyWith(scheduledAt: event.scheduledAt)); 346 348 } 347 349 348 350 Future<void> _onScheduleCleared(ScheduleCleared event, Emitter<ComposeState> emit) async { 351 + if (state.isEditing) return; 349 352 emit(state.copyWith(scheduledAt: null)); 350 353 } 351 354 ··· 372 375 emit(state.copyWith(quoteUri: null, quoteCid: null)); 373 376 } 374 377 378 + Future<void> _onEditContextSet(EditContextSet event, Emitter<ComposeState> emit) async { 379 + final text = event.initialText ?? state.text; 380 + final graphemeCount = text.characters.length; 381 + final isOverLimit = graphemeCount > kMaxGraphemes; 382 + final isEmpty = text.trim().isEmpty; 383 + 384 + emit( 385 + state.copyWith( 386 + text: text, 387 + graphemeCount: graphemeCount, 388 + isOverLimit: isOverLimit, 389 + isEmpty: isEmpty, 390 + canSubmit: !isOverLimit && !isEmpty, 391 + editPostUri: event.postUri, 392 + editPostCid: event.postCid, 393 + editRecord: Map<String, dynamic>.from(event.record), 394 + scheduledAt: null, 395 + isDraftDirty: false, 396 + ), 397 + ); 398 + } 399 + 375 400 Future<void> _onPostSubmitted(PostSubmitted event, Emitter<ComposeState> emit) async { 376 401 if (!state.canSubmit || state.isOverLimit) return; 377 402 378 403 emit(state.copyWith(status: ComposeStatus.submitting, canSubmit: false)); 379 404 380 405 try { 406 + final facets = await _collectFacets(); 407 + 408 + if (state.isEditing) { 409 + final editPostUri = state.editPostUri; 410 + final editPostCid = state.editPostCid; 411 + final editRecord = state.editRecord; 412 + 413 + if (editPostUri == null || editPostCid == null || editRecord == null) { 414 + _emitError(emit, 'Edit context is missing. Please reopen the editor and try again.'); 415 + return; 416 + } 417 + 418 + final result = await _composeRepository.editPost( 419 + postUri: editPostUri, 420 + currentCid: editPostCid, 421 + originalRecord: editRecord, 422 + text: state.text, 423 + facets: facets, 424 + repo: _accountDid, 425 + ); 426 + 427 + if (result.isSuccess) { 428 + emit(state.copyWith(status: ComposeStatus.success, canSubmit: false, isDraftDirty: false)); 429 + } else { 430 + _emitError(emit, result.errorMessage ?? 'Failed to save changes. Please try again.'); 431 + } 432 + return; 433 + } 434 + 381 435 if (state.scheduledAt != null && state.scheduledAt!.isAfter(DateTime.now())) { 382 436 final embedJson = _buildEmbedJson(); 383 437 final draft = DraftsCompanion( ··· 398 452 await PostScheduler.schedulePost(draftId: draftId, scheduledAt: state.scheduledAt!); 399 453 emit(state.copyWith(status: ComposeStatus.success, canSubmit: false)); 400 454 return; 401 - } 402 - 403 - final blueskyText = BlueskyText(state.text); 404 - final facets = <Map<String, dynamic>>[]; 405 - for (final entity in blueskyText.entities) { 406 - try { 407 - final facet = await entity.toFacet().timeout( 408 - const Duration(seconds: 5), 409 - onTimeout: () { 410 - log.w('Timeout resolving @${entity.value}; facet dropped.'); 411 - return {}; 412 - }, 413 - ); 414 - if (facet.isNotEmpty) facets.add(facet); 415 - } catch (e) { 416 - log.w('Could not resolve facet for "${entity.value}": $e'); 417 - } 418 455 } 419 456 420 457 Map<String, dynamic>? embed; ··· 504 541 } catch (e, stackTrace) { 505 542 log.e('Failed to submit post', error: e, stackTrace: stackTrace); 506 543 544 + if (state.isEditing) { 545 + _emitError(emit, 'Failed to save changes: $e'); 546 + return; 547 + } 548 + 549 + await _saveFailedSubmissionAsDraft(emit, e); 550 + } 551 + } 552 + 553 + Future<List<Map<String, dynamic>>> _collectFacets() async { 554 + final blueskyText = BlueskyText(state.text); 555 + final facets = <Map<String, dynamic>>[]; 556 + 557 + for (final entity in blueskyText.entities) { 507 558 try { 508 - final embedJson = _buildEmbedJson(); 509 - final draft = DraftsCompanion( 510 - accountDid: Value(_accountDid), 511 - content: Value(state.text), 512 - replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), 513 - replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), 514 - rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), 515 - rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), 516 - embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), 517 - mediaPaths: state.mediaAttachments.isNotEmpty 518 - ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) 519 - : const Value.absent(), 520 - scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), 521 - updatedAt: Value(DateTime.now()), 559 + final facet = await entity.toFacet().timeout( 560 + const Duration(seconds: 5), 561 + onTimeout: () { 562 + log.w('Timeout resolving @${entity.value}; facet dropped.'); 563 + return {}; 564 + }, 522 565 ); 523 - await _database.saveDraft(draft); 524 - _emitError(emit, 'Network error — post saved as draft.'); 525 - } catch (_) { 526 - _emitError(emit, 'Failed to submit post: $e'); 566 + if (facet.isNotEmpty) facets.add(facet); 567 + } catch (e) { 568 + log.w('Could not resolve facet for "${entity.value}": $e'); 527 569 } 528 570 } 571 + 572 + return facets; 573 + } 574 + 575 + Future<void> _saveFailedSubmissionAsDraft(Emitter<ComposeState> emit, Object error) async { 576 + try { 577 + final embedJson = _buildEmbedJson(); 578 + final draft = DraftsCompanion( 579 + accountDid: Value(_accountDid), 580 + content: Value(state.text), 581 + replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), 582 + replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), 583 + rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), 584 + rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), 585 + embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), 586 + mediaPaths: state.mediaAttachments.isNotEmpty 587 + ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) 588 + : const Value.absent(), 589 + scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), 590 + updatedAt: Value(DateTime.now()), 591 + ); 592 + await _database.saveDraft(draft); 593 + _emitError(emit, 'Network error — post saved as draft.'); 594 + } catch (_) { 595 + _emitError(emit, 'Failed to submit post: $error'); 596 + } 529 597 } 530 598 531 599 /// Emits error state (preserving content), then transitions back to ready ··· 581 649 final String altText; 582 650 final int? width; 583 651 final int? height; 652 + } 653 + 654 + class EditPostResult { 655 + const EditPostResult._({required this.isSuccess, this.errorMessage, this.cid}); 656 + 657 + const EditPostResult.success({required String cid}) : this._(isSuccess: true, cid: cid); 658 + 659 + const EditPostResult.failure(String message) : this._(isSuccess: false, errorMessage: message); 660 + 661 + final bool isSuccess; 662 + final String? errorMessage; 663 + final String? cid; 584 664 } 585 665 586 666 class ComposeRepository { ··· 655 735 return true; 656 736 } catch (e, stackTrace) { 657 737 log.e('Failed to create post', error: e, stackTrace: stackTrace); 738 + return false; 739 + } 740 + } 741 + 742 + Future<EditPostResult> editPost({ 743 + required String postUri, 744 + required String currentCid, 745 + required Map<String, dynamic> originalRecord, 746 + required String text, 747 + required List<Map<String, dynamic>> facets, 748 + required String repo, 749 + }) async { 750 + try { 751 + final atUri = AtUri.parse(postUri); 752 + final targetRepo = atUri.hostname.isNotEmpty ? atUri.hostname : repo; 753 + final collection = atUri.collection.toString(); 754 + final rkey = atUri.rkey; 755 + final latest = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); 756 + 757 + final baseRecord = latest.data.value.isNotEmpty ? latest.data.value : originalRecord; 758 + final swapCid = latest.data.cid ?? currentCid; 759 + final updatedRecord = Map<String, dynamic>.from(baseRecord); 760 + updatedRecord['text'] = text; 761 + if (facets.isNotEmpty) { 762 + updatedRecord['facets'] = facets; 763 + } else { 764 + updatedRecord.remove('facets'); 765 + } 766 + 767 + final existingCreatedAt = baseRecord['createdAt']; 768 + if (existingCreatedAt is String && existingCreatedAt.trim().isNotEmpty) { 769 + updatedRecord['createdAt'] = existingCreatedAt; 770 + } else { 771 + updatedRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); 772 + } 773 + updatedRecord[r'$type'] = 'app.bsky.feed.post'; 774 + 775 + await _bluesky.atproto.repo.deleteRecord( 776 + repo: targetRepo, 777 + collection: collection, 778 + rkey: rkey, 779 + swapRecord: swapCid, 780 + ); 781 + 782 + late final String newCid; 783 + try { 784 + final created = await _bluesky.atproto.repo.createRecord( 785 + repo: targetRepo, 786 + collection: collection, 787 + rkey: rkey, 788 + record: updatedRecord, 789 + ); 790 + newCid = created.data.cid; 791 + } on XRPCException catch (e, stackTrace) { 792 + log.e('Failed to recreate post during edit; checking current state', error: e, stackTrace: stackTrace); 793 + 794 + final snapshot = await _tryGetRecordSnapshot(repo: targetRepo, collection: collection, rkey: rkey); 795 + if (snapshot != null) { 796 + final persistedText = snapshot.value['text']; 797 + if (persistedText is String && persistedText == text) { 798 + return EditPostResult.success(cid: snapshot.cid ?? currentCid); 799 + } 800 + return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); 801 + } 802 + 803 + final restored = await _restoreOriginalRecord( 804 + repo: targetRepo, 805 + collection: collection, 806 + rkey: rkey, 807 + originalRecord: baseRecord, 808 + ); 809 + if (restored) { 810 + return const EditPostResult.failure('Could not save changes. Your original post was restored.'); 811 + } 812 + 813 + return const EditPostResult.failure( 814 + 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.', 815 + ); 816 + } 817 + 818 + final verified = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); 819 + 820 + final persistedText = verified.data.value['text']; 821 + if (persistedText is! String || persistedText != text) { 822 + return const EditPostResult.failure( 823 + 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.', 824 + ); 825 + } 826 + 827 + return EditPostResult.success(cid: newCid); 828 + } on XRPCException catch (e, stackTrace) { 829 + final errorCode = e.response.data.error; 830 + final errorMessage = e.response.data.message ?? ''; 831 + log.e('Failed to edit post', error: e, stackTrace: stackTrace); 832 + 833 + if (errorCode == 'InvalidSwap' || errorMessage.contains('Record was at')) { 834 + return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); 835 + } 836 + 837 + if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { 838 + return const EditPostResult.failure('This post is no longer available. Reopen the thread and try again.'); 839 + } 840 + 841 + return EditPostResult.failure( 842 + errorMessage.isNotEmpty ? errorMessage : 'Failed to save changes. Please try again.', 843 + ); 844 + } catch (e, stackTrace) { 845 + log.e('Failed to edit post', error: e, stackTrace: stackTrace); 846 + return const EditPostResult.failure('Failed to save changes. Please try again.'); 847 + } 848 + } 849 + 850 + Future<({Map<String, dynamic> value, String? cid})?> _tryGetRecordSnapshot({ 851 + required String repo, 852 + required String collection, 853 + required String rkey, 854 + }) async { 855 + try { 856 + final response = await _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey); 857 + return (value: response.data.value, cid: response.data.cid); 858 + } on XRPCException catch (e, stackTrace) { 859 + final errorCode = e.response.data.error; 860 + if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { 861 + return null; 862 + } 863 + log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); 864 + return null; 865 + } catch (e, stackTrace) { 866 + log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); 867 + return null; 868 + } 869 + } 870 + 871 + Future<bool> _restoreOriginalRecord({ 872 + required String repo, 873 + required String collection, 874 + required String rkey, 875 + required Map<String, dynamic> originalRecord, 876 + }) async { 877 + final restoredRecord = Map<String, dynamic>.from(originalRecord); 878 + restoredRecord[r'$type'] = 'app.bsky.feed.post'; 879 + final existingCreatedAt = restoredRecord['createdAt']; 880 + if (existingCreatedAt is! String || existingCreatedAt.trim().isEmpty) { 881 + restoredRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); 882 + } 883 + 884 + try { 885 + await _bluesky.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord); 886 + return true; 887 + } catch (e, stackTrace) { 888 + log.e('Failed to restore original record after edit failure', error: e, stackTrace: stackTrace); 658 889 return false; 659 890 } 660 891 }
+12
lib/features/compose/bloc/compose_event.dart
··· 145 145 class QuoteContextCleared extends ComposeEvent { 146 146 const QuoteContextCleared(); 147 147 } 148 + 149 + class EditContextSet extends ComposeEvent { 150 + const EditContextSet({required this.postUri, required this.postCid, required this.record, this.initialText}); 151 + 152 + final String postUri; 153 + final String postCid; 154 + final Map<String, dynamic> record; 155 + final String? initialText; 156 + 157 + @override 158 + List<Object?> get props => [postUri, postCid, record, initialText]; 159 + }
+24 -2
lib/features/compose/bloc/compose_state.dart
··· 75 75 this.replyRootCid, 76 76 this.quoteUri, 77 77 this.quoteCid, 78 + this.editPostUri, 79 + this.editPostCid, 80 + this.editRecord, 78 81 this.errorMessage, 79 82 this.drafts = const [], 80 83 this.isSavingDraft = false, ··· 100 103 String? replyRootCid, 101 104 String? quoteUri, 102 105 String? quoteCid, 106 + String? editPostUri, 107 + String? editPostCid, 108 + Map<String, dynamic>? editRecord, 103 109 VideoAttachment? videoAttachment, 104 110 bool isDraftDirty = true, 105 111 }) : this._( ··· 117 123 replyRootCid: replyRootCid, 118 124 quoteUri: quoteUri, 119 125 quoteCid: quoteCid, 126 + editPostUri: editPostUri, 127 + editPostCid: editPostCid, 128 + editRecord: editRecord, 120 129 videoAttachment: videoAttachment, 121 130 canSubmit: !isOverLimit && !isEmpty, 122 131 isDraftDirty: isDraftDirty, ··· 136 145 final String? replyRootCid; 137 146 final String? quoteUri; 138 147 final String? quoteCid; 148 + final String? editPostUri; 149 + final String? editPostCid; 150 + final Map<String, dynamic>? editRecord; 139 151 final String? errorMessage; 140 152 final List<DraftEntry> drafts; 141 153 final bool isSavingDraft; ··· 150 162 bool get isReady => status == ComposeStatus.ready; 151 163 bool get hasMedia => mediaAttachments.isNotEmpty; 152 164 bool get hasVideo => videoAttachment != null; 153 - bool get canAddMoreMedia => mediaAttachments.length < 4 && videoAttachment == null; 154 - bool get canAddVideo => mediaAttachments.isEmpty && videoAttachment == null; 165 + bool get canAddMoreMedia => !isEditing && mediaAttachments.length < 4 && videoAttachment == null; 166 + bool get canAddVideo => !isEditing && mediaAttachments.isEmpty && videoAttachment == null; 155 167 bool get hasScheduledTime => scheduledAt != null; 156 168 bool get isReply => replyParentUri != null; 157 169 bool get isQuote => quoteUri != null; 170 + bool get isEditing => editPostUri != null && editPostCid != null && editRecord != null; 158 171 159 172 ComposeState copyWith({ 160 173 ComposeStatus? status, ··· 171 184 Object? replyRootCid = const _Undefined(), 172 185 Object? quoteUri = const _Undefined(), 173 186 Object? quoteCid = const _Undefined(), 187 + Object? editPostUri = const _Undefined(), 188 + Object? editPostCid = const _Undefined(), 189 + Object? editRecord = const _Undefined(), 174 190 Object? errorMessage = const _Undefined(), 175 191 List<DraftEntry>? drafts, 176 192 bool? isSavingDraft, ··· 194 210 replyRootCid: replyRootCid is _Undefined ? this.replyRootCid : replyRootCid as String?, 195 211 quoteUri: quoteUri is _Undefined ? this.quoteUri : quoteUri as String?, 196 212 quoteCid: quoteCid is _Undefined ? this.quoteCid : quoteCid as String?, 213 + editPostUri: editPostUri is _Undefined ? this.editPostUri : editPostUri as String?, 214 + editPostCid: editPostCid is _Undefined ? this.editPostCid : editPostCid as String?, 215 + editRecord: editRecord is _Undefined ? this.editRecord : editRecord as Map<String, dynamic>?, 197 216 errorMessage: errorMessage is _Undefined ? this.errorMessage : errorMessage as String?, 198 217 drafts: drafts ?? this.drafts, 199 218 isSavingDraft: isSavingDraft ?? this.isSavingDraft, ··· 220 239 replyRootCid, 221 240 quoteUri, 222 241 quoteCid, 242 + editPostUri, 243 + editPostCid, 244 + editRecord, 223 245 errorMessage, 224 246 drafts, 225 247 isSavingDraft,
+6
lib/features/compose/presentation/compose_route_args.dart
··· 10 10 this.quoteAuthorHandle, 11 11 this.draftId, 12 12 this.initialText, 13 + this.editPostUri, 14 + this.editPostCid, 15 + this.editRecord, 13 16 }); 14 17 15 18 final String? replyParentUri; ··· 22 25 final String? quoteAuthorHandle; 23 26 final int? draftId; 24 27 final String? initialText; 28 + final String? editPostUri; 29 + final String? editPostCid; 30 + final Map<String, dynamic>? editRecord; 25 31 }
+139 -25
lib/features/compose/presentation/compose_screen.dart
··· 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:image_picker/image_picker.dart'; 9 9 import 'package:intl/intl.dart'; 10 + import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 10 11 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 11 12 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 - import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 13 13 14 14 class ComposeScreen extends StatefulWidget { 15 15 const ComposeScreen({ ··· 24 24 this.quoteAuthorHandle, 25 25 this.draftId, 26 26 this.initialText, 27 + this.editPostUri, 28 + this.editPostCid, 29 + this.editRecord, 27 30 }); 28 31 29 32 final String? replyParentUri; ··· 36 39 final String? quoteAuthorHandle; 37 40 final int? draftId; 38 41 final String? initialText; 42 + final String? editPostUri; 43 + final String? editPostCid; 44 + final Map<String, dynamic>? editRecord; 39 45 40 46 @override 41 47 State<ComposeScreen> createState() => _ComposeScreenState(); ··· 49 55 @override 50 56 void initState() { 51 57 super.initState(); 58 + final isEditing = widget.editPostUri != null && widget.editPostCid != null && widget.editRecord != null; 52 59 _textController = _FacetHighlightController(); 53 60 if (widget.initialText?.isNotEmpty ?? false) { 54 61 _textController.text = widget.initialText!; 55 62 } 56 63 57 - if (widget.draftId != null) { 64 + if (isEditing) { 65 + context.read<ComposeBloc>().add( 66 + EditContextSet( 67 + postUri: widget.editPostUri!, 68 + postCid: widget.editPostCid!, 69 + record: Map<String, dynamic>.from(widget.editRecord!), 70 + initialText: widget.initialText, 71 + ), 72 + ); 73 + } 74 + 75 + if (!isEditing && widget.draftId != null) { 58 76 context.read<ComposeBloc>().add(DraftLoaded(widget.draftId!)); 59 77 } 60 78 61 - if (widget.replyParentUri != null && widget.replyParentCid != null) { 79 + if (!isEditing && widget.replyParentUri != null && widget.replyParentCid != null) { 62 80 context.read<ComposeBloc>().add( 63 81 ReplyContextSet( 64 82 parentUri: widget.replyParentUri!, ··· 69 87 ); 70 88 } 71 89 72 - if (widget.quoteUri != null && widget.quoteCid != null) { 90 + if (!isEditing && widget.quoteUri != null && widget.quoteCid != null) { 73 91 context.read<ComposeBloc>().add(QuoteContextSet(quoteUri: widget.quoteUri!, quoteCid: widget.quoteCid!)); 74 92 } 75 93 76 94 _textController.addListener(_onTextChanged); 77 - if (widget.initialText?.isNotEmpty ?? false) { 95 + if (!isEditing && widget.initialText?.isNotEmpty == true) { 78 96 context.read<ComposeBloc>().add(TextChanged(widget.initialText!)); 79 97 } 80 98 } ··· 261 279 } 262 280 263 281 void _toggleDrafts() { 282 + if (context.read<ComposeBloc>().state.isEditing) return; 264 283 final willShow = !_showDrafts; 265 284 setState(() => _showDrafts = willShow); 266 285 if (willShow) { ··· 425 444 } 426 445 427 446 void _saveDraft() { 447 + if (context.read<ComposeBloc>().state.isEditing) return; 428 448 context.read<ComposeBloc>().add(const DraftSaved()); 429 449 if (mounted) { 430 450 ScaffoldMessenger.of(context).showSnackBar( ··· 436 456 } 437 457 } 438 458 459 + void _showEditAlgorithmInfo() { 460 + showDialog<void>( 461 + context: context, 462 + builder: (dialogContext) => AlertDialog( 463 + title: const Text('How Post Editing Works'), 464 + content: const Text( 465 + 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' 466 + 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', 467 + ), 468 + actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('OK'))], 469 + ), 470 + ); 471 + } 472 + 439 473 void _handleBackNavigation(BuildContext context) { 440 474 final state = context.read<ComposeBloc>().state; 441 475 final navigator = Navigator.of(context); 442 476 443 477 final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; 444 478 479 + if (state.isEditing) { 480 + if (state.isDraftDirty) { 481 + showDialog<bool>( 482 + context: context, 483 + builder: (dialogContext) => AlertDialog( 484 + title: const Text('Discard Changes?'), 485 + content: const Text('You have unsaved edits. Discard them and leave?'), 486 + actions: [ 487 + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), 488 + TextButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Discard')), 489 + ], 490 + ), 491 + ).then((shouldDiscard) { 492 + if (shouldDiscard == true && mounted) { 493 + navigator.pop(false); 494 + } 495 + }); 496 + } else { 497 + navigator.pop(false); 498 + } 499 + return; 500 + } 501 + 445 502 if (hasContent && state.isDraftDirty) { 446 503 showDialog<bool>( 447 504 context: context, ··· 480 537 Widget build(BuildContext context) { 481 538 return BlocListener<ComposeBloc, ComposeState>( 482 539 listener: (context, state) { 483 - final theme = Theme.of(context); 484 - 485 540 if (state.text != _textController.text) { 486 541 _textController.text = state.text; 487 542 _textController.selection = TextSelection.collapsed(offset: state.text.length); 488 543 } 489 544 490 545 if (state.isSuccess) { 491 - Navigator.of(context).pop(); 546 + if (state.isEditing) { 547 + ScaffoldMessenger.of( 548 + context, 549 + ).showSnackBar(const SnackBar(content: Text('Changes saved.'), behavior: SnackBarBehavior.floating)); 550 + } 551 + Navigator.of(context).pop(state.isEditing ? {'editedText': state.text} : null); 492 552 } 493 553 494 554 if (state.hasError && state.errorMessage != null) { 495 - ScaffoldMessenger.of(context).showSnackBar( 496 - SnackBar( 497 - content: Text(state.errorMessage!, style: TextStyle(color: theme.colorScheme.error)), 498 - ), 499 - ); 555 + ScaffoldMessenger.of( 556 + context, 557 + ).showSnackBar(SnackBar(content: Text(state.errorMessage!), behavior: SnackBarBehavior.floating)); 500 558 } 501 559 }, 502 560 child: PopScope( ··· 509 567 appBar: AppBar( 510 568 leading: TextButton(onPressed: () => _handleBackNavigation(context), child: const Text('Cancel')), 511 569 leadingWidth: 80, 512 - title: const Text('New Post'), 570 + title: BlocBuilder<ComposeBloc, ComposeState>( 571 + builder: (context, state) => Text(state.isEditing ? 'Edit Post' : 'New Post'), 572 + ), 513 573 centerTitle: true, 514 574 actions: [ 515 - TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), 575 + BlocBuilder<ComposeBloc, ComposeState>( 576 + builder: (context, state) => state.isEditing 577 + ? const SizedBox.shrink() 578 + : TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), 579 + ), 516 580 BlocBuilder<ComposeBloc, ComposeState>( 517 581 builder: (context, state) { 518 582 final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); ··· 520 584 onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, 521 585 child: state.isSubmitting 522 586 ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 523 - : const Text('Post'), 587 + : Text(state.isEditing ? 'Save Changes' : 'Post'), 524 588 ); 525 589 526 590 return Padding( ··· 538 602 children: [ 539 603 BlocBuilder<ComposeBloc, ComposeState>( 540 604 builder: (context, state) { 605 + if (state.isEditing) { 606 + return Container( 607 + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 608 + padding: const EdgeInsets.all(12), 609 + decoration: BoxDecoration( 610 + color: _theme.colorScheme.surfaceContainerHighest, 611 + border: Border.all(color: _theme.colorScheme.outlineVariant), 612 + ), 613 + child: Row( 614 + crossAxisAlignment: CrossAxisAlignment.start, 615 + children: [ 616 + Icon(Icons.info_outline, color: _theme.colorScheme.onSurfaceVariant, size: 20), 617 + const SizedBox(width: 12), 618 + Expanded( 619 + child: Text( 620 + 'Edits are saved by replacing the record while keeping this post URI. Ranking, ' 621 + 'counts, and visibility may shift while networks re-index.', 622 + style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), 623 + ), 624 + ), 625 + IconButton( 626 + onPressed: _showEditAlgorithmInfo, 627 + icon: const Icon(Icons.help_outline), 628 + tooltip: 'More info', 629 + ), 630 + ], 631 + ), 632 + ); 633 + } 634 + 541 635 if (!state.isReply || widget.replyAuthorHandle == null) { 542 636 return const SizedBox.shrink(); 543 637 } ··· 623 717 624 718 BlocBuilder<ComposeBloc, ComposeState>( 625 719 builder: (context, state) { 720 + if (state.isEditing) { 721 + return const SizedBox.shrink(); 722 + } 626 723 if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); 627 724 628 725 return Container( ··· 695 792 ), 696 793 BlocBuilder<ComposeBloc, ComposeState>( 697 794 builder: (context, state) { 795 + if (state.isEditing) { 796 + return const SizedBox.shrink(); 797 + } 698 798 final video = state.videoAttachment; 699 799 if (video == null) return const SizedBox.shrink(); 700 800 ··· 785 885 AnimatedSize( 786 886 duration: const Duration(milliseconds: 200), 787 887 curve: Curves.easeInOut, 788 - child: _showDrafts ? _buildDraftsPanel() : const SizedBox.shrink(), 888 + child: context.select<ComposeBloc, bool>((bloc) => bloc.state.isEditing) 889 + ? const SizedBox.shrink() 890 + : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), 789 891 ), 790 892 const SizedBox(height: 8), 791 893 Container( ··· 798 900 children: [ 799 901 BlocBuilder<ComposeBloc, ComposeState>( 800 902 builder: (context, state) { 903 + if (state.isEditing) return const SizedBox.shrink(); 801 904 return IconButton( 802 905 onPressed: state.canAddMoreMedia ? _pickImage : null, 803 906 icon: Icon( ··· 812 915 ), 813 916 BlocBuilder<ComposeBloc, ComposeState>( 814 917 builder: (context, state) { 918 + if (state.isEditing) return const SizedBox.shrink(); 815 919 return IconButton( 816 920 onPressed: state.canAddVideo ? _pickVideo : null, 817 921 icon: Icon( ··· 824 928 ); 825 929 }, 826 930 ), 827 - IconButton( 828 - onPressed: _toggleDrafts, 829 - icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), 830 - tooltip: 'Drafts', 931 + BlocBuilder<ComposeBloc, ComposeState>( 932 + builder: (context, state) { 933 + if (state.isEditing) return const SizedBox.shrink(); 934 + return IconButton( 935 + onPressed: _toggleDrafts, 936 + icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), 937 + tooltip: 'Drafts', 938 + ); 939 + }, 831 940 ), 832 - IconButton( 833 - onPressed: _showSchedulePicker, 834 - icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), 835 - tooltip: 'Schedule', 941 + BlocBuilder<ComposeBloc, ComposeState>( 942 + builder: (context, state) { 943 + if (state.isEditing) return const SizedBox.shrink(); 944 + return IconButton( 945 + onPressed: _showSchedulePicker, 946 + icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), 947 + tooltip: 'Schedule', 948 + ); 949 + }, 836 950 ), 837 951 const Spacer(), 838 952 BlocBuilder<ComposeBloc, ComposeState>(
+93
lib/features/feed/presentation/post_thread_screen.dart
··· 868 868 ), 869 869 if (post.author.did == accountDid) 870 870 ListTile( 871 + leading: const Icon(Icons.edit_outlined), 872 + title: const Text('Edit Post'), 873 + onTap: () { 874 + Navigator.pop(sheetContext); 875 + unawaited(_onEdit(context)); 876 + }, 877 + ), 878 + if (post.author.did == accountDid) 879 + ListTile( 871 880 leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 872 881 title: Text('Delete Post', style: TextStyle(color: Theme.of(context).colorScheme.error)), 873 882 onTap: () { ··· 881 890 ); 882 891 } 883 892 893 + Future<void> _onEdit(BuildContext context) async { 894 + final post = thread.post; 895 + final record = Map<String, dynamic>.from(post.record); 896 + 897 + // TODO surface this action from timeline/search/saved cards once those entry points expose onMore. 898 + // TODO add edit affordance to additional owner-post contexts 899 + final result = await context.push( 900 + '/compose', 901 + extra: ComposeRouteArgs( 902 + initialText: _editableTextFromRecord(record), 903 + editPostUri: post.uri.toString(), 904 + editPostCid: post.cid, 905 + editRecord: record, 906 + ), 907 + ); 908 + 909 + if (!context.mounted) return; 910 + 911 + final didSave = result == true || result is Map; 912 + if (!didSave) return; 913 + 914 + String? expectedText; 915 + if (result is Map) { 916 + final editedText = result['editedText']; 917 + if (editedText is String) { 918 + expectedText = editedText; 919 + } 920 + } 921 + 922 + await _reloadThreadAfterEdit(context, postUri: post.uri.toString(), expectedText: expectedText); 923 + } 924 + 925 + Future<void> _reloadThreadAfterEdit(BuildContext context, {required String postUri, String? expectedText}) async { 926 + final cubit = context.read<PostThreadCubit>(); 927 + final retryDelays = <Duration>[ 928 + Duration.zero, 929 + const Duration(seconds: 1), 930 + const Duration(seconds: 2), 931 + const Duration(seconds: 4), 932 + ]; 933 + 934 + for (var i = 0; i < retryDelays.length; i++) { 935 + final delay = retryDelays[i]; 936 + if (delay > Duration.zero) { 937 + await Future<void>.delayed(delay); 938 + } 939 + if (!context.mounted) return; 940 + 941 + await cubit.load(postUri); 942 + 943 + if (expectedText == null) { 944 + return; 945 + } 946 + 947 + final loadedThread = cubit.state.thread; 948 + if (loadedThread == null) { 949 + continue; 950 + } 951 + 952 + final loadedText = _editableTextFromRecord(loadedThread.post.record); 953 + if (loadedText == expectedText) { 954 + return; 955 + } 956 + } 957 + 958 + if (context.mounted && expectedText != null) { 959 + ScaffoldMessenger.of(context).showSnackBar( 960 + const SnackBar( 961 + content: Text('Edit saved. Your updates may take a moment to appear across feeds.'), 962 + behavior: SnackBarBehavior.floating, 963 + ), 964 + ); 965 + } 966 + } 967 + 884 968 void _showReportDialog(BuildContext context) { 885 969 final post = thread.post; 886 970 ··· 942 1026 943 1027 String _formatTimestamp(DateTime time) { 944 1028 return DateFormat('h:mm a · MMM d, yyyy').format(time.toLocal()); 1029 + } 1030 + 1031 + String _editableTextFromRecord(Map<String, dynamic> record) { 1032 + final parsed = _parsePostRecord(record); 1033 + if (parsed != null) { 1034 + return parsed.text; 1035 + } 1036 + final text = record['text']; 1037 + return text is String ? text : ''; 945 1038 } 946 1039 947 1040 String _convertAtUriToBskyUrl(String atUri) {
+15
test/core/router/compose_route_extra_parser_test.dart
··· 29 29 expect(parsed.replyAuthorHandle, 'alice.bsky.social'); 30 30 }); 31 31 32 + test('parses edit context fields from map payload', () { 33 + final parsed = parseComposeRouteExtra({ 34 + 'initialText': 'updated post', 35 + 'editPostUri': 'at://did:plc:test/app.bsky.feed.post/abc123', 36 + 'editPostCid': 'cid-123', 37 + 'editRecord': {r'$type': 'app.bsky.feed.post', 'text': 'old post', 'createdAt': '2026-04-14T10:00:00.000Z'}, 38 + }); 39 + 40 + expect(parsed.initialText, 'updated post'); 41 + expect(parsed.editPostUri, 'at://did:plc:test/app.bsky.feed.post/abc123'); 42 + expect(parsed.editPostCid, 'cid-123'); 43 + expect(parsed.editRecord, isNotNull); 44 + expect(parsed.editRecord!['text'], 'old post'); 45 + }); 46 + 32 47 test('returns empty args for unsupported payload types', () { 33 48 final parsed = parseComposeRouteExtra(42); 34 49
+191
test/features/compose/bloc/compose_bloc_test.dart
··· 739 739 verify(() => mockDatabase.deleteDraft(7)).called(1); 740 740 }, 741 741 ); 742 + 743 + blocTest<ComposeBloc, ComposeState>( 744 + 'edits post via repository when edit context is set', 745 + build: () { 746 + when( 747 + () => mockRepository.editPost( 748 + postUri: any(named: 'postUri'), 749 + currentCid: any(named: 'currentCid'), 750 + originalRecord: any(named: 'originalRecord'), 751 + text: any(named: 'text'), 752 + facets: any(named: 'facets'), 753 + repo: any(named: 'repo'), 754 + ), 755 + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); 756 + return composeBloc; 757 + }, 758 + seed: () => const ComposeState.ready( 759 + text: 'Updated text', 760 + graphemeCount: 12, 761 + isEmpty: false, 762 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 763 + editPostCid: 'cid-current', 764 + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, 765 + isDraftDirty: true, 766 + ), 767 + act: (bloc) => bloc.add(const PostSubmitted()), 768 + expect: () => [ 769 + isA<ComposeState>().having((s) => s.isSubmitting, 'isSubmitting', true), 770 + isA<ComposeState>() 771 + .having((s) => s.isSuccess, 'isSuccess', true) 772 + .having((s) => s.isDraftDirty, 'isDraftDirty', false), 773 + ], 774 + verify: (_) { 775 + verifyNever( 776 + () => mockRepository.createPost( 777 + text: any(named: 'text'), 778 + facets: any(named: 'facets'), 779 + embed: any(named: 'embed'), 780 + reply: any(named: 'reply'), 781 + repo: any(named: 'repo'), 782 + ), 783 + ); 784 + verify( 785 + () => mockRepository.editPost( 786 + postUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 787 + currentCid: 'cid-current', 788 + originalRecord: any(named: 'originalRecord'), 789 + text: 'Updated text', 790 + facets: any(named: 'facets'), 791 + repo: 'did:plc:test', 792 + ), 793 + ).called(1); 794 + }, 795 + ); 796 + 797 + blocTest<ComposeBloc, ComposeState>( 798 + 'passes original non-text fields and keeps createdAt when editing', 799 + build: () { 800 + when( 801 + () => mockRepository.editPost( 802 + postUri: any(named: 'postUri'), 803 + currentCid: any(named: 'currentCid'), 804 + originalRecord: any(named: 'originalRecord'), 805 + text: any(named: 'text'), 806 + facets: any(named: 'facets'), 807 + repo: any(named: 'repo'), 808 + ), 809 + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); 810 + return composeBloc; 811 + }, 812 + seed: () => const ComposeState.ready( 813 + text: 'Revised post body', 814 + graphemeCount: 16, 815 + isEmpty: false, 816 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 817 + editPostCid: 'cid-current', 818 + editRecord: { 819 + r'$type': 'app.bsky.feed.post', 820 + 'text': 'Original post body', 821 + 'createdAt': '2025-01-01T00:00:00.000Z', 822 + 'reply': { 823 + 'parent': {'uri': 'at://did:plc:test/app.bsky.feed.post/parent', 'cid': 'cid-parent'}, 824 + 'root': {'uri': 'at://did:plc:test/app.bsky.feed.post/root', 'cid': 'cid-root'}, 825 + }, 826 + 'embed': { 827 + r'$type': 'app.bsky.embed.record', 828 + 'record': {'uri': 'at://did:plc:test/app.bsky.feed.post/quote', 'cid': 'cid-quote'}, 829 + }, 830 + }, 831 + ), 832 + act: (bloc) => bloc.add(const PostSubmitted()), 833 + verify: (_) { 834 + final invocation = 835 + verify( 836 + () => mockRepository.editPost( 837 + postUri: any(named: 'postUri'), 838 + currentCid: any(named: 'currentCid'), 839 + originalRecord: captureAny(named: 'originalRecord'), 840 + text: any(named: 'text'), 841 + facets: any(named: 'facets'), 842 + repo: any(named: 'repo'), 843 + ), 844 + ).captured.single 845 + as Map<String, dynamic>; 846 + 847 + expect(invocation['createdAt'], '2025-01-01T00:00:00.000Z'); 848 + expect(invocation['reply'], isNotNull); 849 + expect(invocation['embed'], isNotNull); 850 + }, 851 + ); 852 + 853 + blocTest<ComposeBloc, ComposeState>( 854 + 'passes empty facets list for plain text edits', 855 + build: () { 856 + when( 857 + () => mockRepository.editPost( 858 + postUri: any(named: 'postUri'), 859 + currentCid: any(named: 'currentCid'), 860 + originalRecord: any(named: 'originalRecord'), 861 + text: any(named: 'text'), 862 + facets: any(named: 'facets'), 863 + repo: any(named: 'repo'), 864 + ), 865 + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); 866 + return composeBloc; 867 + }, 868 + seed: () => const ComposeState.ready( 869 + text: 'No facets here', 870 + graphemeCount: 13, 871 + isEmpty: false, 872 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 873 + editPostCid: 'cid-current', 874 + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, 875 + ), 876 + act: (bloc) => bloc.add(const PostSubmitted()), 877 + verify: (_) { 878 + final facets = 879 + verify( 880 + () => mockRepository.editPost( 881 + postUri: any(named: 'postUri'), 882 + currentCid: any(named: 'currentCid'), 883 + originalRecord: any(named: 'originalRecord'), 884 + text: any(named: 'text'), 885 + facets: captureAny(named: 'facets'), 886 + repo: any(named: 'repo'), 887 + ), 888 + ).captured.single 889 + as List<Map<String, dynamic>>; 890 + expect(facets, isEmpty); 891 + }, 892 + ); 893 + 894 + blocTest<ComposeBloc, ComposeState>( 895 + 'surfaces InvalidSwap edit failures as user-visible errors', 896 + build: () { 897 + when( 898 + () => mockRepository.editPost( 899 + postUri: any(named: 'postUri'), 900 + currentCid: any(named: 'currentCid'), 901 + originalRecord: any(named: 'originalRecord'), 902 + text: any(named: 'text'), 903 + facets: any(named: 'facets'), 904 + repo: any(named: 'repo'), 905 + ), 906 + ).thenAnswer( 907 + (_) async => 908 + const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'), 909 + ); 910 + return composeBloc; 911 + }, 912 + seed: () => const ComposeState.ready( 913 + text: 'Updated text', 914 + graphemeCount: 12, 915 + isEmpty: false, 916 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 917 + editPostCid: 'cid-current', 918 + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, 919 + ), 920 + act: (bloc) => bloc.add(const PostSubmitted()), 921 + expect: () => [ 922 + isA<ComposeState>().having((s) => s.isSubmitting, 'isSubmitting', true), 923 + isA<ComposeState>() 924 + .having((s) => s.hasError, 'hasError', true) 925 + .having( 926 + (s) => s.errorMessage, 927 + 'errorMessage', 928 + 'This post was changed elsewhere. Reopen it and try editing again.', 929 + ), 930 + isA<ComposeState>().having((s) => s.isReady, 'isReady', true), 931 + ], 932 + ); 742 933 }); 743 934 }); 744 935 }
+114 -11
test/features/compose/presentation/compose_screen_test.dart
··· 36 36 setUp(() { 37 37 registerFallbackValue(FakeDraftsCompanion()); 38 38 registerFallbackValue(const TextChanged('')); 39 + registerFallbackValue( 40 + const EditContextSet( 41 + postUri: 'at://did:plc:test/app.bsky.feed.post/fallback', 42 + postCid: 'cid-fallback', 43 + record: {r'$type': 'app.bsky.feed.post', 'text': 'fallback', 'createdAt': '2026-04-14T10:00:00.000Z'}, 44 + ), 45 + ); 39 46 mockBloc = MockComposeBloc(); 40 47 connectivityCubit = MockConnectivityCubit(); 41 48 when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); ··· 50 57 mockBloc.close(); 51 58 }); 52 59 53 - Widget buildSubject() => MaterialApp( 60 + Widget buildSubject({ComposeScreen screen = const ComposeScreen()}) => MaterialApp( 54 61 home: MultiBlocProvider( 55 62 providers: [ 56 63 BlocProvider<ComposeBloc>.value(value: mockBloc), 57 64 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 58 65 ], 59 - child: const ComposeScreen(), 66 + child: screen, 60 67 ), 61 68 ); 62 69 ··· 96 103 testWidgets('prefills initial text and dispatches TextChanged when provided', (tester) async { 97 104 seedState(const ComposeState.ready(text: '@river.bsky.social ', graphemeCount: 19, isEmpty: false)); 98 105 106 + await tester.pumpWidget(buildSubject(screen: const ComposeScreen(initialText: '@river.bsky.social '))); 107 + await tester.pump(); 108 + 109 + expect(find.text('@river.bsky.social '), findsOneWidget); 110 + verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); 111 + }); 112 + }); 113 + 114 + group('edit mode', () { 115 + testWidgets('shows edit title, save action, and algorithm notice banner', (tester) async { 116 + seedState( 117 + const ComposeState.ready( 118 + text: 'Updated text', 119 + graphemeCount: 12, 120 + isEmpty: false, 121 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 122 + editPostCid: 'cid-current', 123 + editRecord: { 124 + r'$type': 'app.bsky.feed.post', 125 + 'text': 'Original text', 126 + 'createdAt': '2026-04-14T10:00:00.000Z', 127 + }, 128 + ), 129 + ); 130 + 131 + await tester.pumpWidget(buildSubject()); 132 + await tester.pump(); 133 + 134 + expect(find.text('Edit Post'), findsOneWidget); 135 + expect(find.text('Save Changes'), findsOneWidget); 136 + expect( 137 + find.text( 138 + 'Edits are saved by replacing the record while keeping this post URI. Ranking, counts, and visibility may ' 139 + 'shift while networks re-index.', 140 + ), 141 + findsOneWidget, 142 + ); 143 + }); 144 + 145 + testWidgets('hides unsupported controls while editing', (tester) async { 146 + seedState( 147 + const ComposeState.ready( 148 + text: 'Updated text', 149 + graphemeCount: 12, 150 + isEmpty: false, 151 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 152 + editPostCid: 'cid-current', 153 + editRecord: { 154 + r'$type': 'app.bsky.feed.post', 155 + 'text': 'Original text', 156 + 'createdAt': '2026-04-14T10:00:00.000Z', 157 + }, 158 + ), 159 + ); 160 + 161 + await tester.pumpWidget(buildSubject()); 162 + await tester.pump(); 163 + 164 + expect(find.text('Save Draft'), findsNothing); 165 + expect(find.byIcon(Icons.image_outlined), findsNothing); 166 + expect(find.byIcon(Icons.videocam_outlined), findsNothing); 167 + expect(find.byIcon(Icons.drive_file_rename_outline), findsNothing); 168 + expect(find.byIcon(Icons.schedule), findsNothing); 169 + }); 170 + 171 + testWidgets('dispatches EditContextSet on init when edit args are provided', (tester) async { 172 + seedState( 173 + const ComposeState.ready( 174 + text: 'Original text', 175 + graphemeCount: 13, 176 + isEmpty: false, 177 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 178 + editPostCid: 'cid-current', 179 + editRecord: { 180 + r'$type': 'app.bsky.feed.post', 181 + 'text': 'Original text', 182 + 'createdAt': '2026-04-14T10:00:00.000Z', 183 + }, 184 + ), 185 + ); 186 + 99 187 await tester.pumpWidget( 100 - MaterialApp( 101 - home: MultiBlocProvider( 102 - providers: [ 103 - BlocProvider<ComposeBloc>.value(value: mockBloc), 104 - BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 105 - ], 106 - child: const ComposeScreen(initialText: '@river.bsky.social '), 188 + buildSubject( 189 + screen: const ComposeScreen( 190 + initialText: 'Original text', 191 + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 192 + editPostCid: 'cid-current', 193 + editRecord: { 194 + r'$type': 'app.bsky.feed.post', 195 + 'text': 'Original text', 196 + 'createdAt': '2026-04-14T10:00:00.000Z', 197 + }, 107 198 ), 108 199 ), 109 200 ); 110 201 await tester.pump(); 111 202 112 - expect(find.text('@river.bsky.social '), findsOneWidget); 113 - verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); 203 + verify( 204 + () => mockBloc.add( 205 + const EditContextSet( 206 + postUri: 'at://did:plc:test/app.bsky.feed.post/abc123', 207 + postCid: 'cid-current', 208 + record: { 209 + r'$type': 'app.bsky.feed.post', 210 + 'text': 'Original text', 211 + 'createdAt': '2026-04-14T10:00:00.000Z', 212 + }, 213 + initialText: 'Original text', 214 + ), 215 + ), 216 + ).called(1); 114 217 }); 115 218 }); 116 219
+252
test/features/feed/presentation/post_thread_edit_flow_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 5 + import 'package:bluesky/app_bsky_feed_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_getlikes.dart'; 7 + import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 8 + import 'package:flutter/material.dart'; 9 + import 'package:flutter_bloc/flutter_bloc.dart'; 10 + import 'package:flutter_test/flutter_test.dart'; 11 + import 'package:go_router/go_router.dart'; 12 + import 'package:lazurite/core/theme/app_theme.dart'; 13 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 15 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 16 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 17 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 18 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 19 + import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 20 + import 'package:lazurite/features/search/data/search_scope.dart'; 21 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 22 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 23 + import 'package:mocktail/mocktail.dart'; 24 + 25 + class MockPostThreadRepository extends Mock implements PostThreadRepository {} 26 + 27 + class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 28 + 29 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 30 + 31 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 32 + 33 + class _FakePostActionRepository implements PostActionRepository { 34 + @override 35 + Future<void> createBookmark({required AtUri uri, required String cid}) async {} 36 + 37 + @override 38 + Future<void> deleteBookmark({required AtUri uri}) async {} 39 + 40 + @override 41 + Future<void> deletePost({required String postUri}) async {} 42 + 43 + @override 44 + Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 45 + return const BookmarkGetBookmarksOutput(bookmarks: []); 46 + } 47 + 48 + @override 49 + Future<FeedGetLikesOutput> getLikes({required AtUri uri, String? cursor}) async { 50 + return FeedGetLikesOutput(uri: uri, likes: []); 51 + } 52 + 53 + @override 54 + Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 55 + return FeedGetRepostedByOutput(uri: uri, repostedBy: []); 56 + } 57 + 58 + @override 59 + Future<String> likePost({required AtUri uri, required String cid}) async => 'at://did:plc:test/app.bsky.feed.like/1'; 60 + 61 + @override 62 + Future<String> repostPost({required AtUri uri, required String cid}) async { 63 + return 'at://did:plc:test/app.bsky.feed.repost/1'; 64 + } 65 + 66 + @override 67 + Future<void> unlikePost({required String likeUri}) async {} 68 + 69 + @override 70 + Future<void> unrepostPost({required String repostUri}) async {} 71 + } 72 + 73 + PostView _makePost({ 74 + required String did, 75 + required String handle, 76 + required String rkey, 77 + required String text, 78 + DateTime? createdAt, 79 + }) { 80 + final time = createdAt ?? DateTime.utc(2026, 4, 14, 12); 81 + return PostView( 82 + uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), 83 + cid: 'cid-$rkey', 84 + author: ProfileViewBasic(did: did, handle: handle), 85 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': time.toIso8601String()}, 86 + indexedAt: time, 87 + ); 88 + } 89 + 90 + ThreadViewPost _makeThread({required String did, required String handle, required String rkey, required String text}) { 91 + return ThreadViewPost( 92 + post: _makePost(did: did, handle: handle, rkey: rkey, text: text), 93 + ); 94 + } 95 + 96 + void main() { 97 + late MockPostThreadRepository postThreadRepository; 98 + late MockSavedPostsCubit savedPostsCubit; 99 + late MockConnectivityCubit connectivityCubit; 100 + late MockSettingsCubit settingsCubit; 101 + final postActionRepository = _FakePostActionRepository(); 102 + 103 + const postUri = 'at://did:plc:owner/app.bsky.feed.post/root'; 104 + 105 + setUp(() { 106 + postThreadRepository = MockPostThreadRepository(); 107 + savedPostsCubit = MockSavedPostsCubit(); 108 + connectivityCubit = MockConnectivityCubit(); 109 + settingsCubit = MockSettingsCubit(); 110 + 111 + const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); 112 + when(() => savedPostsCubit.state).thenReturn(savedState); 113 + whenListen(savedPostsCubit, const Stream<SavedPostsState>.empty(), initialState: savedState); 114 + 115 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 116 + whenListen( 117 + connectivityCubit, 118 + const Stream<ConnectivityState>.empty(), 119 + initialState: const ConnectivityState.online(), 120 + ); 121 + 122 + const settingsState = SettingsState( 123 + themePalette: AppThemePalette.lazurite, 124 + themeVariant: AppThemeVariant.dark, 125 + useSystemTheme: false, 126 + searchScope: SearchScope.both, 127 + ); 128 + when(() => settingsCubit.state).thenReturn(settingsState); 129 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: settingsState); 130 + }); 131 + 132 + Widget createSubjectWidget({ 133 + required String accountDid, 134 + required ThreadViewPost thread, 135 + required ValueSetter<ComposeRouteArgs> onComposeArgs, 136 + }) { 137 + final router = GoRouter( 138 + routes: [ 139 + GoRoute( 140 + path: '/', 141 + builder: (context, state) => const PostThreadScreen(postUri: postUri), 142 + ), 143 + GoRoute( 144 + path: '/compose', 145 + builder: (context, state) { 146 + final args = state.extra as ComposeRouteArgs; 147 + onComposeArgs(args); 148 + return Scaffold( 149 + body: Center( 150 + child: ElevatedButton( 151 + key: const ValueKey('complete-edit'), 152 + onPressed: () => Navigator.of(context).pop(true), 153 + child: const Text('Complete Edit'), 154 + ), 155 + ), 156 + ); 157 + }, 158 + ), 159 + ], 160 + ); 161 + 162 + when(() => postThreadRepository.getPostThread(postUri)).thenAnswer((_) async => thread); 163 + 164 + return MultiRepositoryProvider( 165 + providers: [ 166 + RepositoryProvider<PostThreadRepository>.value(value: postThreadRepository), 167 + RepositoryProvider<PostActionRepository>.value(value: postActionRepository), 168 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 169 + RepositoryProvider<String>.value(value: accountDid), 170 + ], 171 + child: MultiBlocProvider( 172 + providers: [ 173 + BlocProvider<SavedPostsCubit>.value(value: savedPostsCubit), 174 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 175 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 176 + ], 177 + child: MaterialApp.router(routerConfig: router), 178 + ), 179 + ); 180 + } 181 + 182 + testWidgets('shows Edit Post only for the author and sends edit payload to compose', (tester) async { 183 + ComposeRouteArgs? capturedArgs; 184 + final thread = _makeThread( 185 + did: 'did:plc:owner', 186 + handle: 'owner.bsky.social', 187 + rkey: 'root', 188 + text: 'Original post body', 189 + ); 190 + 191 + await tester.pumpWidget( 192 + createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (args) => capturedArgs = args), 193 + ); 194 + await tester.pumpAndSettle(); 195 + 196 + await tester.tap(find.byIcon(Icons.more_vert).first); 197 + await tester.pumpAndSettle(); 198 + 199 + expect(find.text('Edit Post'), findsOneWidget); 200 + 201 + await tester.tap(find.text('Edit Post')); 202 + await tester.pumpAndSettle(); 203 + 204 + expect(find.byKey(const ValueKey('complete-edit')), findsOneWidget); 205 + expect(capturedArgs, isNotNull); 206 + expect(capturedArgs!.editPostUri, postUri); 207 + expect(capturedArgs!.editPostCid, 'cid-root'); 208 + expect(capturedArgs!.initialText, 'Original post body'); 209 + expect(capturedArgs!.editRecord?['text'], 'Original post body'); 210 + }); 211 + 212 + testWidgets('does not show Edit Post when viewing someone else\'s post', (tester) async { 213 + final thread = _makeThread( 214 + did: 'did:plc:other', 215 + handle: 'other.bsky.social', 216 + rkey: 'root', 217 + text: 'Other user post', 218 + ); 219 + 220 + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); 221 + await tester.pumpAndSettle(); 222 + 223 + await tester.tap(find.byIcon(Icons.more_vert).first); 224 + await tester.pumpAndSettle(); 225 + 226 + expect(find.text('Edit Post'), findsNothing); 227 + }); 228 + 229 + testWidgets('reloads thread after edit flow returns success', (tester) async { 230 + final thread = _makeThread( 231 + did: 'did:plc:owner', 232 + handle: 'owner.bsky.social', 233 + rkey: 'root', 234 + text: 'Original post body', 235 + ); 236 + 237 + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); 238 + await tester.pumpAndSettle(); 239 + 240 + verify(() => postThreadRepository.getPostThread(postUri)).called(1); 241 + 242 + await tester.tap(find.byIcon(Icons.more_vert).first); 243 + await tester.pumpAndSettle(); 244 + await tester.tap(find.text('Edit Post')); 245 + await tester.pumpAndSettle(); 246 + 247 + await tester.tap(find.byKey(const ValueKey('complete-edit'))); 248 + await tester.pumpAndSettle(); 249 + 250 + verify(() => postThreadRepository.getPostThread(postUri)).called(1); 251 + }); 252 + }