diff --git a/docs/specs/post-editing.md b/docs/specs/post-editing.md new file mode 100644 index 0000000..a7474dd --- /dev/null +++ b/docs/specs/post-editing.md @@ -0,0 +1,101 @@ +--- +title: Post Editing Spec (v1) +updated: 2026-04-14 +--- + +## Summary + +Add AT Protocol post editing to Lazurite by replacing post records via +`com.atproto.repo.deleteRecord` + `com.atproto.repo.createRecord` (same `rkey`, +same URI), with a v1 scope of: + +- Entry point: thread screen only +- Editable fields: post text + regenerated facets +- Preserved fields: reply/embed/langs/labels/tags/unknown fields +- Concurrency control: `swapRecord` with the current post CID + +## Protocol Mechanics + +### Record Replacement + +Use delete + recreate on the existing `app.bsky.feed.post` rkey: + +- `repo`: authenticated account DID +- `collection`: `app.bsky.feed.post` +- `rkey`: extracted from post AT-URI +- Delete guard: `swapRecord` with latest/current CID from the post view +- Recreate: `createRecord` with the same `rkey` to preserve AT-URI + +The edit payload is built from the original record, replacing only: + +- `text` +- `facets` (recomputed from updated text; removed when empty) + +`createdAt` is preserved from the original record when present. If missing or +invalid, fallback to current UTC timestamp as a defensive safeguard. + +### Conflict Handling + +When delete/recreate detects stale state (`InvalidSwap`) or changed ownership, +Lazurite treats the edit as a conflict and shows a non-merge message instructing +the user to reopen and retry. + +If recreate fails after delete, Lazurite attempts defensive recovery by +restoring the original record on the same `rkey`. + +## UX and Flow + +### Thread Entry + +For author-owned posts in the thread action sheet: + +- Add `Edit Post` action. +- Navigate to compose with edit context: + - `editPostUri` + - `editPostCid` + - `editRecord` + - `initialText` + +On successful edit completion, refresh the thread by reloading the current +post URI. + +### Compose Edit Mode + +Compose supports explicit edit mode via route context. + +Edit-mode behavior: + +- Title/action labels switch to edit wording (`Edit Post`, `Save Changes`) +- Inline algorithm-impact notice is shown with an info dialog +- Unsupported create-flow controls are disabled/hidden: + - Save Draft + - Schedule + - Add/remove image + - Add/remove video +- Submission performs `putRecord` update rather than `createRecord` + +## Algorithmic Implications (User Notice) + +Editing can change how the post is indexed and distributed: + +- Post metadata like `indexedAt` may change after edits +- Feed ranking and search visibility may shift after re-indexing +- Read-after-write propagation can be delayed across services and surfaces +- Because edits are saved as delete+recreate on the same URI, counters and + visibility can briefly lag while services reconcile state + +Lazurite informs users inline in compose edit mode and provides an info action +for additional context. + +## Limitations + +- Edit action is exposed only in thread view +- Only text/facets are user-editable +- No merge flow for edit conflicts + +## Beyond + +- Add edit entry points in timeline/search/saved post cards +- Consider edit-history affordances and richer conflict resolution UX + - The question here is where do we store history? What happens between logins? At what + point does Lazurite need its own lexicons for features like this? diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1f4d412..f23e0f3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -81,6 +81,17 @@ ComposeRouteArgs parseComposeRouteExtra(Object? extra) { return null; } + Map? readMap(String key) { + final value = extra[key]; + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + return null; + } + return ComposeRouteArgs( replyParentUri: readString('replyParentUri'), replyParentCid: readString('replyParentCid'), @@ -92,6 +103,9 @@ ComposeRouteArgs parseComposeRouteExtra(Object? extra) { quoteAuthorHandle: readString('quoteAuthorHandle'), draftId: readInt('draftId'), initialText: readString('initialText'), + editPostUri: readString('editPostUri'), + editPostCid: readString('editPostCid'), + editRecord: readMap('editRecord'), ); } @@ -152,6 +166,9 @@ class AppRouter { quoteAuthorHandle: args.quoteAuthorHandle, draftId: args.draftId, initialText: args.initialText, + editPostUri: args.editPostUri, + editPostCid: args.editPostCid, + editRecord: args.editRecord, ), ); }, diff --git a/lib/features/compose/bloc/compose_bloc.dart b/lib/features/compose/bloc/compose_bloc.dart index 9562bcc..220bfbb 100644 --- a/lib/features/compose/bloc/compose_bloc.dart +++ b/lib/features/compose/bloc/compose_bloc.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; -import 'package:atproto_core/atproto_core.dart' show Blob, BlobRef; +import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef, XRPCException; import 'package:bluesky/bluesky.dart'; import 'package:bluesky/app_bsky_video_defs.dart' show KnownJobStatusState; import 'package:bluesky_text/bluesky_text.dart'; @@ -53,6 +53,7 @@ class ComposeBloc extends Bloc { on(_onReplyContextCleared); on(_onQuoteContextSet); on(_onQuoteContextCleared); + on(_onEditContextSet); } final ComposeRepository _composeRepository; @@ -342,10 +343,12 @@ class ComposeBloc extends Bloc { } Future _onPostScheduled(PostScheduled event, Emitter emit) async { + if (state.isEditing) return; emit(state.copyWith(scheduledAt: event.scheduledAt)); } Future _onScheduleCleared(ScheduleCleared event, Emitter emit) async { + if (state.isEditing) return; emit(state.copyWith(scheduledAt: null)); } @@ -372,12 +375,63 @@ class ComposeBloc extends Bloc { emit(state.copyWith(quoteUri: null, quoteCid: null)); } + Future _onEditContextSet(EditContextSet event, Emitter emit) async { + final text = event.initialText ?? state.text; + final graphemeCount = text.characters.length; + final isOverLimit = graphemeCount > kMaxGraphemes; + final isEmpty = text.trim().isEmpty; + + emit( + state.copyWith( + text: text, + graphemeCount: graphemeCount, + isOverLimit: isOverLimit, + isEmpty: isEmpty, + canSubmit: !isOverLimit && !isEmpty, + editPostUri: event.postUri, + editPostCid: event.postCid, + editRecord: Map.from(event.record), + scheduledAt: null, + isDraftDirty: false, + ), + ); + } + Future _onPostSubmitted(PostSubmitted event, Emitter emit) async { if (!state.canSubmit || state.isOverLimit) return; emit(state.copyWith(status: ComposeStatus.submitting, canSubmit: false)); try { + final facets = await _collectFacets(); + + if (state.isEditing) { + final editPostUri = state.editPostUri; + final editPostCid = state.editPostCid; + final editRecord = state.editRecord; + + if (editPostUri == null || editPostCid == null || editRecord == null) { + _emitError(emit, 'Edit context is missing. Please reopen the editor and try again.'); + return; + } + + final result = await _composeRepository.editPost( + postUri: editPostUri, + currentCid: editPostCid, + originalRecord: editRecord, + text: state.text, + facets: facets, + repo: _accountDid, + ); + + if (result.isSuccess) { + emit(state.copyWith(status: ComposeStatus.success, canSubmit: false, isDraftDirty: false)); + } else { + _emitError(emit, result.errorMessage ?? 'Failed to save changes. Please try again.'); + } + return; + } + if (state.scheduledAt != null && state.scheduledAt!.isAfter(DateTime.now())) { final embedJson = _buildEmbedJson(); final draft = DraftsCompanion( @@ -400,23 +454,6 @@ class ComposeBloc extends Bloc { return; } - final blueskyText = BlueskyText(state.text); - final facets = >[]; - for (final entity in blueskyText.entities) { - try { - final facet = await entity.toFacet().timeout( - const Duration(seconds: 5), - onTimeout: () { - log.w('Timeout resolving @${entity.value}; facet dropped.'); - return {}; - }, - ); - if (facet.isNotEmpty) facets.add(facet); - } catch (e) { - log.w('Could not resolve facet for "${entity.value}": $e'); - } - } - Map? embed; if (state.mediaAttachments.isNotEmpty) { @@ -504,28 +541,59 @@ class ComposeBloc extends Bloc { } catch (e, stackTrace) { log.e('Failed to submit post', error: e, stackTrace: stackTrace); + if (state.isEditing) { + _emitError(emit, 'Failed to save changes: $e'); + return; + } + + await _saveFailedSubmissionAsDraft(emit, e); + } + } + + Future>> _collectFacets() async { + final blueskyText = BlueskyText(state.text); + final facets = >[]; + + for (final entity in blueskyText.entities) { try { - final embedJson = _buildEmbedJson(); - final draft = DraftsCompanion( - accountDid: Value(_accountDid), - content: Value(state.text), - replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), - replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), - rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), - rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), - embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), - mediaPaths: state.mediaAttachments.isNotEmpty - ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) - : const Value.absent(), - scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), - updatedAt: Value(DateTime.now()), + final facet = await entity.toFacet().timeout( + const Duration(seconds: 5), + onTimeout: () { + log.w('Timeout resolving @${entity.value}; facet dropped.'); + return {}; + }, ); - await _database.saveDraft(draft); - _emitError(emit, 'Network error — post saved as draft.'); - } catch (_) { - _emitError(emit, 'Failed to submit post: $e'); + if (facet.isNotEmpty) facets.add(facet); + } catch (e) { + log.w('Could not resolve facet for "${entity.value}": $e'); } } + + return facets; + } + + Future _saveFailedSubmissionAsDraft(Emitter emit, Object error) async { + try { + final embedJson = _buildEmbedJson(); + final draft = DraftsCompanion( + accountDid: Value(_accountDid), + content: Value(state.text), + replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), + replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), + rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), + rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), + embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), + mediaPaths: state.mediaAttachments.isNotEmpty + ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) + : const Value.absent(), + scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), + updatedAt: Value(DateTime.now()), + ); + await _database.saveDraft(draft); + _emitError(emit, 'Network error — post saved as draft.'); + } catch (_) { + _emitError(emit, 'Failed to submit post: $error'); + } } /// Emits error state (preserving content), then transitions back to ready @@ -583,6 +651,18 @@ class _UploadedImage { final int? height; } +class EditPostResult { + const EditPostResult._({required this.isSuccess, this.errorMessage, this.cid}); + + const EditPostResult.success({required String cid}) : this._(isSuccess: true, cid: cid); + + const EditPostResult.failure(String message) : this._(isSuccess: false, errorMessage: message); + + final bool isSuccess; + final String? errorMessage; + final String? cid; +} + class ComposeRepository { ComposeRepository({required Bluesky bluesky}) : _bluesky = bluesky; @@ -658,6 +738,157 @@ class ComposeRepository { return false; } } + + Future editPost({ + required String postUri, + required String currentCid, + required Map originalRecord, + required String text, + required List> facets, + required String repo, + }) async { + try { + final atUri = AtUri.parse(postUri); + final targetRepo = atUri.hostname.isNotEmpty ? atUri.hostname : repo; + final collection = atUri.collection.toString(); + final rkey = atUri.rkey; + final latest = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); + + final baseRecord = latest.data.value.isNotEmpty ? latest.data.value : originalRecord; + final swapCid = latest.data.cid ?? currentCid; + final updatedRecord = Map.from(baseRecord); + updatedRecord['text'] = text; + if (facets.isNotEmpty) { + updatedRecord['facets'] = facets; + } else { + updatedRecord.remove('facets'); + } + + final existingCreatedAt = baseRecord['createdAt']; + if (existingCreatedAt is String && existingCreatedAt.trim().isNotEmpty) { + updatedRecord['createdAt'] = existingCreatedAt; + } else { + updatedRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); + } + updatedRecord[r'$type'] = 'app.bsky.feed.post'; + + await _bluesky.atproto.repo.deleteRecord( + repo: targetRepo, + collection: collection, + rkey: rkey, + swapRecord: swapCid, + ); + + late final String newCid; + try { + final created = await _bluesky.atproto.repo.createRecord( + repo: targetRepo, + collection: collection, + rkey: rkey, + record: updatedRecord, + ); + newCid = created.data.cid; + } on XRPCException catch (e, stackTrace) { + log.e('Failed to recreate post during edit; checking current state', error: e, stackTrace: stackTrace); + + final snapshot = await _tryGetRecordSnapshot(repo: targetRepo, collection: collection, rkey: rkey); + if (snapshot != null) { + final persistedText = snapshot.value['text']; + if (persistedText is String && persistedText == text) { + return EditPostResult.success(cid: snapshot.cid ?? currentCid); + } + return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); + } + + final restored = await _restoreOriginalRecord( + repo: targetRepo, + collection: collection, + rkey: rkey, + originalRecord: baseRecord, + ); + if (restored) { + return const EditPostResult.failure('Could not save changes. Your original post was restored.'); + } + + return const EditPostResult.failure( + 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.', + ); + } + + final verified = await _bluesky.atproto.repo.getRecord(repo: targetRepo, collection: collection, rkey: rkey); + + final persistedText = verified.data.value['text']; + if (persistedText is! String || persistedText != text) { + return const EditPostResult.failure( + 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.', + ); + } + + return EditPostResult.success(cid: newCid); + } on XRPCException catch (e, stackTrace) { + final errorCode = e.response.data.error; + final errorMessage = e.response.data.message ?? ''; + log.e('Failed to edit post', error: e, stackTrace: stackTrace); + + if (errorCode == 'InvalidSwap' || errorMessage.contains('Record was at')) { + return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); + } + + if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { + return const EditPostResult.failure('This post is no longer available. Reopen the thread and try again.'); + } + + return EditPostResult.failure( + errorMessage.isNotEmpty ? errorMessage : 'Failed to save changes. Please try again.', + ); + } catch (e, stackTrace) { + log.e('Failed to edit post', error: e, stackTrace: stackTrace); + return const EditPostResult.failure('Failed to save changes. Please try again.'); + } + } + + Future<({Map value, String? cid})?> _tryGetRecordSnapshot({ + required String repo, + required String collection, + required String rkey, + }) async { + try { + final response = await _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey); + return (value: response.data.value, cid: response.data.cid); + } on XRPCException catch (e, stackTrace) { + final errorCode = e.response.data.error; + if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { + return null; + } + log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); + return null; + } catch (e, stackTrace) { + log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); + return null; + } + } + + Future _restoreOriginalRecord({ + required String repo, + required String collection, + required String rkey, + required Map originalRecord, + }) async { + final restoredRecord = Map.from(originalRecord); + restoredRecord[r'$type'] = 'app.bsky.feed.post'; + final existingCreatedAt = restoredRecord['createdAt']; + if (existingCreatedAt is! String || existingCreatedAt.trim().isEmpty) { + restoredRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); + } + + try { + await _bluesky.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord); + return true; + } catch (e, stackTrace) { + log.e('Failed to restore original record after edit failure', error: e, stackTrace: stackTrace); + return false; + } + } } /// Image dimension helper (used by compose screen when picking images). diff --git a/lib/features/compose/bloc/compose_event.dart b/lib/features/compose/bloc/compose_event.dart index 80090b4..2f9fae3 100644 --- a/lib/features/compose/bloc/compose_event.dart +++ b/lib/features/compose/bloc/compose_event.dart @@ -145,3 +145,15 @@ class QuoteContextSet extends ComposeEvent { class QuoteContextCleared extends ComposeEvent { const QuoteContextCleared(); } + +class EditContextSet extends ComposeEvent { + const EditContextSet({required this.postUri, required this.postCid, required this.record, this.initialText}); + + final String postUri; + final String postCid; + final Map record; + final String? initialText; + + @override + List get props => [postUri, postCid, record, initialText]; +} diff --git a/lib/features/compose/bloc/compose_state.dart b/lib/features/compose/bloc/compose_state.dart index 934c08f..9ba87aa 100644 --- a/lib/features/compose/bloc/compose_state.dart +++ b/lib/features/compose/bloc/compose_state.dart @@ -75,6 +75,9 @@ class ComposeState extends Equatable { this.replyRootCid, this.quoteUri, this.quoteCid, + this.editPostUri, + this.editPostCid, + this.editRecord, this.errorMessage, this.drafts = const [], this.isSavingDraft = false, @@ -100,6 +103,9 @@ class ComposeState extends Equatable { String? replyRootCid, String? quoteUri, String? quoteCid, + String? editPostUri, + String? editPostCid, + Map? editRecord, VideoAttachment? videoAttachment, bool isDraftDirty = true, }) : this._( @@ -117,6 +123,9 @@ class ComposeState extends Equatable { replyRootCid: replyRootCid, quoteUri: quoteUri, quoteCid: quoteCid, + editPostUri: editPostUri, + editPostCid: editPostCid, + editRecord: editRecord, videoAttachment: videoAttachment, canSubmit: !isOverLimit && !isEmpty, isDraftDirty: isDraftDirty, @@ -136,6 +145,9 @@ class ComposeState extends Equatable { final String? replyRootCid; final String? quoteUri; final String? quoteCid; + final String? editPostUri; + final String? editPostCid; + final Map? editRecord; final String? errorMessage; final List drafts; final bool isSavingDraft; @@ -150,11 +162,12 @@ class ComposeState extends Equatable { bool get isReady => status == ComposeStatus.ready; bool get hasMedia => mediaAttachments.isNotEmpty; bool get hasVideo => videoAttachment != null; - bool get canAddMoreMedia => mediaAttachments.length < 4 && videoAttachment == null; - bool get canAddVideo => mediaAttachments.isEmpty && videoAttachment == null; + bool get canAddMoreMedia => !isEditing && mediaAttachments.length < 4 && videoAttachment == null; + bool get canAddVideo => !isEditing && mediaAttachments.isEmpty && videoAttachment == null; bool get hasScheduledTime => scheduledAt != null; bool get isReply => replyParentUri != null; bool get isQuote => quoteUri != null; + bool get isEditing => editPostUri != null && editPostCid != null && editRecord != null; ComposeState copyWith({ ComposeStatus? status, @@ -171,6 +184,9 @@ class ComposeState extends Equatable { Object? replyRootCid = const _Undefined(), Object? quoteUri = const _Undefined(), Object? quoteCid = const _Undefined(), + Object? editPostUri = const _Undefined(), + Object? editPostCid = const _Undefined(), + Object? editRecord = const _Undefined(), Object? errorMessage = const _Undefined(), List? drafts, bool? isSavingDraft, @@ -194,6 +210,9 @@ class ComposeState extends Equatable { replyRootCid: replyRootCid is _Undefined ? this.replyRootCid : replyRootCid as String?, quoteUri: quoteUri is _Undefined ? this.quoteUri : quoteUri as String?, quoteCid: quoteCid is _Undefined ? this.quoteCid : quoteCid as String?, + editPostUri: editPostUri is _Undefined ? this.editPostUri : editPostUri as String?, + editPostCid: editPostCid is _Undefined ? this.editPostCid : editPostCid as String?, + editRecord: editRecord is _Undefined ? this.editRecord : editRecord as Map?, errorMessage: errorMessage is _Undefined ? this.errorMessage : errorMessage as String?, drafts: drafts ?? this.drafts, isSavingDraft: isSavingDraft ?? this.isSavingDraft, @@ -220,6 +239,9 @@ class ComposeState extends Equatable { replyRootCid, quoteUri, quoteCid, + editPostUri, + editPostCid, + editRecord, errorMessage, drafts, isSavingDraft, diff --git a/lib/features/compose/presentation/compose_route_args.dart b/lib/features/compose/presentation/compose_route_args.dart index 7b4eb6a..1215716 100644 --- a/lib/features/compose/presentation/compose_route_args.dart +++ b/lib/features/compose/presentation/compose_route_args.dart @@ -10,6 +10,9 @@ class ComposeRouteArgs { this.quoteAuthorHandle, this.draftId, this.initialText, + this.editPostUri, + this.editPostCid, + this.editRecord, }); final String? replyParentUri; @@ -22,4 +25,7 @@ class ComposeRouteArgs { final String? quoteAuthorHandle; final int? draftId; final String? initialText; + final String? editPostUri; + final String? editPostCid; + final Map? editRecord; } diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index beee06a..6d3b019 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -7,9 +7,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; -import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; class ComposeScreen extends StatefulWidget { const ComposeScreen({ @@ -24,6 +24,9 @@ class ComposeScreen extends StatefulWidget { this.quoteAuthorHandle, this.draftId, this.initialText, + this.editPostUri, + this.editPostCid, + this.editRecord, }); final String? replyParentUri; @@ -36,6 +39,9 @@ class ComposeScreen extends StatefulWidget { final String? quoteAuthorHandle; final int? draftId; final String? initialText; + final String? editPostUri; + final String? editPostCid; + final Map? editRecord; @override State createState() => _ComposeScreenState(); @@ -49,16 +55,28 @@ class _ComposeScreenState extends State { @override void initState() { super.initState(); + final isEditing = widget.editPostUri != null && widget.editPostCid != null && widget.editRecord != null; _textController = _FacetHighlightController(); if (widget.initialText?.isNotEmpty ?? false) { _textController.text = widget.initialText!; } - if (widget.draftId != null) { + if (isEditing) { + context.read().add( + EditContextSet( + postUri: widget.editPostUri!, + postCid: widget.editPostCid!, + record: Map.from(widget.editRecord!), + initialText: widget.initialText, + ), + ); + } + + if (!isEditing && widget.draftId != null) { context.read().add(DraftLoaded(widget.draftId!)); } - if (widget.replyParentUri != null && widget.replyParentCid != null) { + if (!isEditing && widget.replyParentUri != null && widget.replyParentCid != null) { context.read().add( ReplyContextSet( parentUri: widget.replyParentUri!, @@ -69,12 +87,12 @@ class _ComposeScreenState extends State { ); } - if (widget.quoteUri != null && widget.quoteCid != null) { + if (!isEditing && widget.quoteUri != null && widget.quoteCid != null) { context.read().add(QuoteContextSet(quoteUri: widget.quoteUri!, quoteCid: widget.quoteCid!)); } _textController.addListener(_onTextChanged); - if (widget.initialText?.isNotEmpty ?? false) { + if (!isEditing && widget.initialText?.isNotEmpty == true) { context.read().add(TextChanged(widget.initialText!)); } } @@ -261,6 +279,7 @@ class _ComposeScreenState extends State { } void _toggleDrafts() { + if (context.read().state.isEditing) return; final willShow = !_showDrafts; setState(() => _showDrafts = willShow); if (willShow) { @@ -425,6 +444,7 @@ class _ComposeScreenState extends State { } void _saveDraft() { + if (context.read().state.isEditing) return; context.read().add(const DraftSaved()); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -436,12 +456,49 @@ class _ComposeScreenState extends State { } } + void _showEditAlgorithmInfo() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('How Post Editing Works'), + content: const Text( + 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' + 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', + ), + actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('OK'))], + ), + ); + } + void _handleBackNavigation(BuildContext context) { final state = context.read().state; final navigator = Navigator.of(context); final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; + if (state.isEditing) { + if (state.isDraftDirty) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Discard Changes?'), + content: const Text('You have unsaved edits. Discard them and leave?'), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Discard')), + ], + ), + ).then((shouldDiscard) { + if (shouldDiscard == true && mounted) { + navigator.pop(false); + } + }); + } else { + navigator.pop(false); + } + return; + } + if (hasContent && state.isDraftDirty) { showDialog( context: context, @@ -480,23 +537,24 @@ class _ComposeScreenState extends State { Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - final theme = Theme.of(context); - if (state.text != _textController.text) { _textController.text = state.text; _textController.selection = TextSelection.collapsed(offset: state.text.length); } if (state.isSuccess) { - Navigator.of(context).pop(); + if (state.isEditing) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Changes saved.'), behavior: SnackBarBehavior.floating)); + } + Navigator.of(context).pop(state.isEditing ? {'editedText': state.text} : null); } if (state.hasError && state.errorMessage != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errorMessage!, style: TextStyle(color: theme.colorScheme.error)), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.errorMessage!), behavior: SnackBarBehavior.floating)); } }, child: PopScope( @@ -509,10 +567,16 @@ class _ComposeScreenState extends State { appBar: AppBar( leading: TextButton(onPressed: () => _handleBackNavigation(context), child: const Text('Cancel')), leadingWidth: 80, - title: const Text('New Post'), + title: BlocBuilder( + builder: (context, state) => Text(state.isEditing ? 'Edit Post' : 'New Post'), + ), centerTitle: true, actions: [ - TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), + BlocBuilder( + builder: (context, state) => state.isEditing + ? const SizedBox.shrink() + : TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), + ), BlocBuilder( builder: (context, state) { final isOffline = context.select((cubit) => cubit.state.isOffline); @@ -520,7 +584,7 @@ class _ComposeScreenState extends State { onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, child: state.isSubmitting ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Post'), + : Text(state.isEditing ? 'Save Changes' : 'Post'), ); return Padding( @@ -538,6 +602,36 @@ class _ComposeScreenState extends State { children: [ BlocBuilder( builder: (context, state) { + if (state.isEditing) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _theme.colorScheme.surfaceContainerHighest, + border: Border.all(color: _theme.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: _theme.colorScheme.onSurfaceVariant, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Edits are saved by replacing the record while keeping this post URI. Ranking, ' + 'counts, and visibility may shift while networks re-index.', + style: _theme.textTheme.bodySmall?.copyWith(color: _theme.colorScheme.onSurfaceVariant), + ), + ), + IconButton( + onPressed: _showEditAlgorithmInfo, + icon: const Icon(Icons.help_outline), + tooltip: 'More info', + ), + ], + ), + ); + } + if (!state.isReply || widget.replyAuthorHandle == null) { return const SizedBox.shrink(); } @@ -623,6 +717,9 @@ class _ComposeScreenState extends State { BlocBuilder( builder: (context, state) { + if (state.isEditing) { + return const SizedBox.shrink(); + } if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); return Container( @@ -695,6 +792,9 @@ class _ComposeScreenState extends State { ), BlocBuilder( builder: (context, state) { + if (state.isEditing) { + return const SizedBox.shrink(); + } final video = state.videoAttachment; if (video == null) return const SizedBox.shrink(); @@ -785,7 +885,9 @@ class _ComposeScreenState extends State { AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, - child: _showDrafts ? _buildDraftsPanel() : const SizedBox.shrink(), + child: context.select((bloc) => bloc.state.isEditing) + ? const SizedBox.shrink() + : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), ), const SizedBox(height: 8), Container( @@ -798,6 +900,7 @@ class _ComposeScreenState extends State { children: [ BlocBuilder( builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); return IconButton( onPressed: state.canAddMoreMedia ? _pickImage : null, icon: Icon( @@ -812,6 +915,7 @@ class _ComposeScreenState extends State { ), BlocBuilder( builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); return IconButton( onPressed: state.canAddVideo ? _pickVideo : null, icon: Icon( @@ -824,15 +928,25 @@ class _ComposeScreenState extends State { ); }, ), - IconButton( - onPressed: _toggleDrafts, - icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), - tooltip: 'Drafts', + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return IconButton( + onPressed: _toggleDrafts, + icon: Icon(Icons.drive_file_rename_outline, color: _theme.colorScheme.primary), + tooltip: 'Drafts', + ); + }, ), - IconButton( - onPressed: _showSchedulePicker, - icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), - tooltip: 'Schedule', + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return IconButton( + onPressed: _showSchedulePicker, + icon: Icon(Icons.schedule, color: _theme.colorScheme.primary), + tooltip: 'Schedule', + ); + }, ), const Spacer(), BlocBuilder( diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 25e3673..70b4ca1 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -866,6 +866,15 @@ class _FocusedPostContent extends StatelessWidget { _showReportDialog(context); }, ), + if (post.author.did == accountDid) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Edit Post'), + onTap: () { + Navigator.pop(sheetContext); + unawaited(_onEdit(context)); + }, + ), if (post.author.did == accountDid) ListTile( leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), @@ -881,6 +890,81 @@ class _FocusedPostContent extends StatelessWidget { ); } + Future _onEdit(BuildContext context) async { + final post = thread.post; + final record = Map.from(post.record); + + // TODO surface this action from timeline/search/saved cards once those entry points expose onMore. + // TODO add edit affordance to additional owner-post contexts + final result = await context.push( + '/compose', + extra: ComposeRouteArgs( + initialText: _editableTextFromRecord(record), + editPostUri: post.uri.toString(), + editPostCid: post.cid, + editRecord: record, + ), + ); + + if (!context.mounted) return; + + final didSave = result == true || result is Map; + if (!didSave) return; + + String? expectedText; + if (result is Map) { + final editedText = result['editedText']; + if (editedText is String) { + expectedText = editedText; + } + } + + await _reloadThreadAfterEdit(context, postUri: post.uri.toString(), expectedText: expectedText); + } + + Future _reloadThreadAfterEdit(BuildContext context, {required String postUri, String? expectedText}) async { + final cubit = context.read(); + final retryDelays = [ + Duration.zero, + const Duration(seconds: 1), + const Duration(seconds: 2), + const Duration(seconds: 4), + ]; + + for (var i = 0; i < retryDelays.length; i++) { + final delay = retryDelays[i]; + if (delay > Duration.zero) { + await Future.delayed(delay); + } + if (!context.mounted) return; + + await cubit.load(postUri); + + if (expectedText == null) { + return; + } + + final loadedThread = cubit.state.thread; + if (loadedThread == null) { + continue; + } + + final loadedText = _editableTextFromRecord(loadedThread.post.record); + if (loadedText == expectedText) { + return; + } + } + + if (context.mounted && expectedText != null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Edit saved. Your updates may take a moment to appear across feeds.'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + void _showReportDialog(BuildContext context) { final post = thread.post; @@ -944,6 +1028,15 @@ class _FocusedPostContent extends StatelessWidget { return DateFormat('h:mm a · MMM d, yyyy').format(time.toLocal()); } + String _editableTextFromRecord(Map record) { + final parsed = _parsePostRecord(record); + if (parsed != null) { + return parsed.text; + } + final text = record['text']; + return text is String ? text : ''; + } + String _convertAtUriToBskyUrl(String atUri) { try { final uri = Uri.parse(atUri); diff --git a/test/core/router/compose_route_extra_parser_test.dart b/test/core/router/compose_route_extra_parser_test.dart index 7e7cad5..b2b340e 100644 --- a/test/core/router/compose_route_extra_parser_test.dart +++ b/test/core/router/compose_route_extra_parser_test.dart @@ -29,6 +29,21 @@ void main() { expect(parsed.replyAuthorHandle, 'alice.bsky.social'); }); + test('parses edit context fields from map payload', () { + final parsed = parseComposeRouteExtra({ + 'initialText': 'updated post', + 'editPostUri': 'at://did:plc:test/app.bsky.feed.post/abc123', + 'editPostCid': 'cid-123', + 'editRecord': {r'$type': 'app.bsky.feed.post', 'text': 'old post', 'createdAt': '2026-04-14T10:00:00.000Z'}, + }); + + expect(parsed.initialText, 'updated post'); + expect(parsed.editPostUri, 'at://did:plc:test/app.bsky.feed.post/abc123'); + expect(parsed.editPostCid, 'cid-123'); + expect(parsed.editRecord, isNotNull); + expect(parsed.editRecord!['text'], 'old post'); + }); + test('returns empty args for unsupported payload types', () { final parsed = parseComposeRouteExtra(42); diff --git a/test/features/compose/bloc/compose_bloc_test.dart b/test/features/compose/bloc/compose_bloc_test.dart index bbc56f1..df550a9 100644 --- a/test/features/compose/bloc/compose_bloc_test.dart +++ b/test/features/compose/bloc/compose_bloc_test.dart @@ -739,6 +739,197 @@ void main() { verify(() => mockDatabase.deleteDraft(7)).called(1); }, ); + + blocTest( + 'edits post via repository when edit context is set', + build: () { + when( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: any(named: 'originalRecord'), + text: any(named: 'text'), + facets: any(named: 'facets'), + repo: any(named: 'repo'), + ), + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); + return composeBloc; + }, + seed: () => const ComposeState.ready( + text: 'Updated text', + graphemeCount: 12, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, + isDraftDirty: true, + ), + act: (bloc) => bloc.add(const PostSubmitted()), + expect: () => [ + isA().having((s) => s.isSubmitting, 'isSubmitting', true), + isA() + .having((s) => s.isSuccess, 'isSuccess', true) + .having((s) => s.isDraftDirty, 'isDraftDirty', false), + ], + verify: (_) { + verifyNever( + () => mockRepository.createPost( + text: any(named: 'text'), + facets: any(named: 'facets'), + embed: any(named: 'embed'), + reply: any(named: 'reply'), + repo: any(named: 'repo'), + ), + ); + verify( + () => mockRepository.editPost( + postUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + currentCid: 'cid-current', + originalRecord: any(named: 'originalRecord'), + text: 'Updated text', + facets: any(named: 'facets'), + repo: 'did:plc:test', + ), + ).called(1); + }, + ); + + blocTest( + 'passes original non-text fields and keeps createdAt when editing', + build: () { + when( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: any(named: 'originalRecord'), + text: any(named: 'text'), + facets: any(named: 'facets'), + repo: any(named: 'repo'), + ), + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); + return composeBloc; + }, + seed: () => const ComposeState.ready( + text: 'Revised post body', + graphemeCount: 16, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original post body', + 'createdAt': '2025-01-01T00:00:00.000Z', + 'reply': { + 'parent': {'uri': 'at://did:plc:test/app.bsky.feed.post/parent', 'cid': 'cid-parent'}, + 'root': {'uri': 'at://did:plc:test/app.bsky.feed.post/root', 'cid': 'cid-root'}, + }, + 'embed': { + r'$type': 'app.bsky.embed.record', + 'record': {'uri': 'at://did:plc:test/app.bsky.feed.post/quote', 'cid': 'cid-quote'}, + }, + }, + ), + act: (bloc) => bloc.add(const PostSubmitted()), + verify: (_) { + final invocation = + verify( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: captureAny(named: 'originalRecord'), + text: any(named: 'text'), + facets: any(named: 'facets'), + repo: any(named: 'repo'), + ), + ).captured.single + as Map; + + expect(invocation['createdAt'], '2025-01-01T00:00:00.000Z'); + expect(invocation['reply'], isNotNull); + expect(invocation['embed'], isNotNull); + }, + ); + + blocTest( + 'passes empty facets list for plain text edits', + build: () { + when( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: any(named: 'originalRecord'), + text: any(named: 'text'), + facets: any(named: 'facets'), + repo: any(named: 'repo'), + ), + ).thenAnswer((_) async => const EditPostResult.success(cid: 'cid-new')); + return composeBloc; + }, + seed: () => const ComposeState.ready( + text: 'No facets here', + graphemeCount: 13, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, + ), + act: (bloc) => bloc.add(const PostSubmitted()), + verify: (_) { + final facets = + verify( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: any(named: 'originalRecord'), + text: any(named: 'text'), + facets: captureAny(named: 'facets'), + repo: any(named: 'repo'), + ), + ).captured.single + as List>; + expect(facets, isEmpty); + }, + ); + + blocTest( + 'surfaces InvalidSwap edit failures as user-visible errors', + build: () { + when( + () => mockRepository.editPost( + postUri: any(named: 'postUri'), + currentCid: any(named: 'currentCid'), + originalRecord: any(named: 'originalRecord'), + text: any(named: 'text'), + facets: any(named: 'facets'), + repo: any(named: 'repo'), + ), + ).thenAnswer( + (_) async => + const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'), + ); + return composeBloc; + }, + seed: () => const ComposeState.ready( + text: 'Updated text', + graphemeCount: 12, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: {r'$type': 'app.bsky.feed.post', 'text': 'Original', 'createdAt': '2026-04-14T10:00:00.000Z'}, + ), + act: (bloc) => bloc.add(const PostSubmitted()), + expect: () => [ + isA().having((s) => s.isSubmitting, 'isSubmitting', true), + isA() + .having((s) => s.hasError, 'hasError', true) + .having( + (s) => s.errorMessage, + 'errorMessage', + 'This post was changed elsewhere. Reopen it and try editing again.', + ), + isA().having((s) => s.isReady, 'isReady', true), + ], + ); }); }); } diff --git a/test/features/compose/presentation/compose_screen_test.dart b/test/features/compose/presentation/compose_screen_test.dart index 5a1d2b5..2adcbc0 100644 --- a/test/features/compose/presentation/compose_screen_test.dart +++ b/test/features/compose/presentation/compose_screen_test.dart @@ -36,6 +36,13 @@ void main() { setUp(() { registerFallbackValue(FakeDraftsCompanion()); registerFallbackValue(const TextChanged('')); + registerFallbackValue( + const EditContextSet( + postUri: 'at://did:plc:test/app.bsky.feed.post/fallback', + postCid: 'cid-fallback', + record: {r'$type': 'app.bsky.feed.post', 'text': 'fallback', 'createdAt': '2026-04-14T10:00:00.000Z'}, + ), + ); mockBloc = MockComposeBloc(); connectivityCubit = MockConnectivityCubit(); when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); @@ -50,13 +57,13 @@ void main() { mockBloc.close(); }); - Widget buildSubject() => MaterialApp( + Widget buildSubject({ComposeScreen screen = const ComposeScreen()}) => MaterialApp( home: MultiBlocProvider( providers: [ BlocProvider.value(value: mockBloc), BlocProvider.value(value: connectivityCubit), ], - child: const ComposeScreen(), + child: screen, ), ); @@ -96,21 +103,117 @@ void main() { testWidgets('prefills initial text and dispatches TextChanged when provided', (tester) async { seedState(const ComposeState.ready(text: '@river.bsky.social ', graphemeCount: 19, isEmpty: false)); + await tester.pumpWidget(buildSubject(screen: const ComposeScreen(initialText: '@river.bsky.social '))); + await tester.pump(); + + expect(find.text('@river.bsky.social '), findsOneWidget); + verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); + }); + }); + + group('edit mode', () { + testWidgets('shows edit title, save action, and algorithm notice banner', (tester) async { + seedState( + const ComposeState.ready( + text: 'Updated text', + graphemeCount: 12, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + }, + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Edit Post'), findsOneWidget); + expect(find.text('Save Changes'), findsOneWidget); + expect( + find.text( + 'Edits are saved by replacing the record while keeping this post URI. Ranking, counts, and visibility may ' + 'shift while networks re-index.', + ), + findsOneWidget, + ); + }); + + testWidgets('hides unsupported controls while editing', (tester) async { + seedState( + const ComposeState.ready( + text: 'Updated text', + graphemeCount: 12, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + }, + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Save Draft'), findsNothing); + expect(find.byIcon(Icons.image_outlined), findsNothing); + expect(find.byIcon(Icons.videocam_outlined), findsNothing); + expect(find.byIcon(Icons.drive_file_rename_outline), findsNothing); + expect(find.byIcon(Icons.schedule), findsNothing); + }); + + testWidgets('dispatches EditContextSet on init when edit args are provided', (tester) async { + seedState( + const ComposeState.ready( + text: 'Original text', + graphemeCount: 13, + isEmpty: false, + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + }, + ), + ); + await tester.pumpWidget( - MaterialApp( - home: MultiBlocProvider( - providers: [ - BlocProvider.value(value: mockBloc), - BlocProvider.value(value: connectivityCubit), - ], - child: const ComposeScreen(initialText: '@river.bsky.social '), + buildSubject( + screen: const ComposeScreen( + initialText: 'Original text', + editPostUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + editPostCid: 'cid-current', + editRecord: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + }, ), ), ); await tester.pump(); - expect(find.text('@river.bsky.social '), findsOneWidget); - verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); + verify( + () => mockBloc.add( + const EditContextSet( + postUri: 'at://did:plc:test/app.bsky.feed.post/abc123', + postCid: 'cid-current', + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'Original text', + 'createdAt': '2026-04-14T10:00:00.000Z', + }, + initialText: 'Original text', + ), + ), + ).called(1); }); }); diff --git a/test/features/feed/presentation/post_thread_edit_flow_test.dart b/test/features/feed/presentation/post_thread_edit_flow_test.dart new file mode 100644 index 0000000..f0e07b9 --- /dev/null +++ b/test/features/feed/presentation/post_thread_edit_flow_test.dart @@ -0,0 +1,252 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_feed_getlikes.dart'; +import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; +import 'package:lazurite/features/search/data/search_scope.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockPostThreadRepository extends Mock implements PostThreadRepository {} + +class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class _FakePostActionRepository implements PostActionRepository { + @override + Future createBookmark({required AtUri uri, required String cid}) async {} + + @override + Future deleteBookmark({required AtUri uri}) async {} + + @override + Future deletePost({required String postUri}) async {} + + @override + Future getBookmarks({int? limit, String? cursor}) async { + return const BookmarkGetBookmarksOutput(bookmarks: []); + } + + @override + Future getLikes({required AtUri uri, String? cursor}) async { + return FeedGetLikesOutput(uri: uri, likes: []); + } + + @override + Future getRepostedBy({required AtUri uri, String? cursor}) async { + return FeedGetRepostedByOutput(uri: uri, repostedBy: []); + } + + @override + Future likePost({required AtUri uri, required String cid}) async => 'at://did:plc:test/app.bsky.feed.like/1'; + + @override + Future repostPost({required AtUri uri, required String cid}) async { + return 'at://did:plc:test/app.bsky.feed.repost/1'; + } + + @override + Future unlikePost({required String likeUri}) async {} + + @override + Future unrepostPost({required String repostUri}) async {} +} + +PostView _makePost({ + required String did, + required String handle, + required String rkey, + required String text, + DateTime? createdAt, +}) { + final time = createdAt ?? DateTime.utc(2026, 4, 14, 12); + return PostView( + uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), + cid: 'cid-$rkey', + author: ProfileViewBasic(did: did, handle: handle), + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': time.toIso8601String()}, + indexedAt: time, + ); +} + +ThreadViewPost _makeThread({required String did, required String handle, required String rkey, required String text}) { + return ThreadViewPost( + post: _makePost(did: did, handle: handle, rkey: rkey, text: text), + ); +} + +void main() { + late MockPostThreadRepository postThreadRepository; + late MockSavedPostsCubit savedPostsCubit; + late MockConnectivityCubit connectivityCubit; + late MockSettingsCubit settingsCubit; + final postActionRepository = _FakePostActionRepository(); + + const postUri = 'at://did:plc:owner/app.bsky.feed.post/root'; + + setUp(() { + postThreadRepository = MockPostThreadRepository(); + savedPostsCubit = MockSavedPostsCubit(); + connectivityCubit = MockConnectivityCubit(); + settingsCubit = MockSettingsCubit(); + + const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); + when(() => savedPostsCubit.state).thenReturn(savedState); + whenListen(savedPostsCubit, const Stream.empty(), initialState: savedState); + + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + + const settingsState = SettingsState( + themePalette: AppThemePalette.lazurite, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + searchScope: SearchScope.both, + ); + when(() => settingsCubit.state).thenReturn(settingsState); + whenListen(settingsCubit, const Stream.empty(), initialState: settingsState); + }); + + Widget createSubjectWidget({ + required String accountDid, + required ThreadViewPost thread, + required ValueSetter onComposeArgs, + }) { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const PostThreadScreen(postUri: postUri), + ), + GoRoute( + path: '/compose', + builder: (context, state) { + final args = state.extra as ComposeRouteArgs; + onComposeArgs(args); + return Scaffold( + body: Center( + child: ElevatedButton( + key: const ValueKey('complete-edit'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Complete Edit'), + ), + ), + ); + }, + ), + ], + ); + + when(() => postThreadRepository.getPostThread(postUri)).thenAnswer((_) async => thread); + + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: postThreadRepository), + RepositoryProvider.value(value: postActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), + RepositoryProvider.value(value: accountDid), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: savedPostsCubit), + BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: settingsCubit), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + } + + testWidgets('shows Edit Post only for the author and sends edit payload to compose', (tester) async { + ComposeRouteArgs? capturedArgs; + final thread = _makeThread( + did: 'did:plc:owner', + handle: 'owner.bsky.social', + rkey: 'root', + text: 'Original post body', + ); + + await tester.pumpWidget( + createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (args) => capturedArgs = args), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert).first); + await tester.pumpAndSettle(); + + expect(find.text('Edit Post'), findsOneWidget); + + await tester.tap(find.text('Edit Post')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('complete-edit')), findsOneWidget); + expect(capturedArgs, isNotNull); + expect(capturedArgs!.editPostUri, postUri); + expect(capturedArgs!.editPostCid, 'cid-root'); + expect(capturedArgs!.initialText, 'Original post body'); + expect(capturedArgs!.editRecord?['text'], 'Original post body'); + }); + + testWidgets('does not show Edit Post when viewing someone else\'s post', (tester) async { + final thread = _makeThread( + did: 'did:plc:other', + handle: 'other.bsky.social', + rkey: 'root', + text: 'Other user post', + ); + + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert).first); + await tester.pumpAndSettle(); + + expect(find.text('Edit Post'), findsNothing); + }); + + testWidgets('reloads thread after edit flow returns success', (tester) async { + final thread = _makeThread( + did: 'did:plc:owner', + handle: 'owner.bsky.social', + rkey: 'root', + text: 'Original post body', + ); + + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); + await tester.pumpAndSettle(); + + verify(() => postThreadRepository.getPostThread(postUri)).called(1); + + await tester.tap(find.byIcon(Icons.more_vert).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Post')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('complete-edit'))); + await tester.pumpAndSettle(); + + verify(() => postThreadRepository.getPostThread(postUri)).called(1); + }); +}