diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4a611e71e..b31bcca80 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -361,7 +361,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf + audio_waveforms: cd736909ebc6b6a164eb74701d8c705ce3241e1c BanubaARCloudSDK: 40da62afeea642301ca8092600c9bb9cf67fe30e BanubaAudioBrowserSDK: 3a92807e60daa70dcaa1e76920abab97e7a73bf4 BanubaLicenseServicingSDK: f2b51a4288d6b9796063855f5db6dc227819de22 @@ -384,48 +384,48 @@ SPEC CHECKSUMS: BNBSdkApi: f773621958cf0f7776e18f90b96ace13b29d84e2 BNBSdkCore: cc7711127dbcaf392d0a25f4a858d98ffc5c9213 BNBSkin: 3a741bbba57a2a860fe4a69dbd597d7d9aea5793 - camera_avfoundation: 04b44aeb14070126c6529e5ab82cc7c9fca107cf - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - external_app_launcher: 3411245965270a74040a3de17e27bd02b8abb905 + external_app_launcher: ad55ac844aa21f2d2197d7cec58ff0d0dc40bbc0 ffmpeg-kit-ios-full-gpl: 80adc341962e55ef709e36baa8ed9a70cf4ea62b - ffmpeg_kit_flutter_full_gpl: ce18b888487c05c46ed252cd2e7956812f2e3bd1 - file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 - file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 + ffmpeg_kit_flutter_full_gpl: 8d15c14c0c3aba616fac04fe44b3d27d02e3c330 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 - flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - image_cropper: 5f162dcf988100dc1513f9c6b7eb42cd6fbf9156 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - ion_screenshot_detector: 6981b7ec0add023ce57337882e28dc4b80caad4b - local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + ion_screenshot_detector: bd85296e1347b5bf5acf71407a6d9b7933bfe604 + local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: 566e1b7a2f3900e4b0020914ad3fc051dcc95596 - passkeys_ios: 939d7d44f825048c8dffd4644f52444164c80ecd - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 - qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a - quill_native_bridge: fd2819cf6da02fb6cbf9de37835f96e798e145eb + package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + passkeys_ios: fdae8c06e2178a9fcb9261a6cb21fb9a06a81d53 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a + qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e + quill_native_bridge: e5afa7d49c08cf68c52a5e23bc272eba6925c622 SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 - shake_gesture_ios: 85dce5e26785da11cf73e0234a5f4028f69afeef - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3 + shake_gesture_ios: 64f1f579f314c58445761992a123111b3d7b3492 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqlite3: 7559e33dae4c78538df563795af3a86fc887ee71 - sqlite3_flutter_libs: f0b59f6bb2a18597d0796558725007e5a7428397 + sqlite3_flutter_libs: 1b4e98da20ebd4e9b1240269b78cdcf492dbe9f3 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 - ua_client_hints: ef4ddde0e2b2be5f0731a31721c4cbbb889b1aa4 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - ve_sdk_flutter: e23c4a6f1b84eeacee1daf2a81ba0f0694ed76f1 + ua_client_hints: 3b617011e47bea4b1ea65647efa12860b7280ad5 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + ve_sdk_flutter: c90ec3fc424953a7b82cd1edd5143bc756899e92 VEEffectsSDK: 40dca6a76aeff8630ce1b49ef7643d2a2655dd0f VEExportSDK: 32e97cf98ecbf86aed563fbd4c9131d7bbddbe92 VEPlaybackSDK: 65bb7377c9c8524557d7b5d6723e321fc16fc4a1 - video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 VideoEditor: b3e55fe6a6589e715365da822172f54b6fea8169 PODFILE CHECKSUM: f19afb20048b05a04dbc809ad7d199f1df053e9a diff --git a/lib/app/components/counter_items_footer/reposts_counter_button.dart b/lib/app/components/counter_items_footer/reposts_counter_button.dart index 41d2c290e..03f39b255 100644 --- a/lib/app/components/counter_items_footer/reposts_counter_button.dart +++ b/lib/app/components/counter_items_footer/reposts_counter_button.dart @@ -30,7 +30,9 @@ class RepostsCounterButton extends ConsumerWidget { return GestureDetector( onTap: () { HapticFeedback.lightImpact(); - RepostOptionsModalRoute(eventReference: eventReference.toString()).push(context); + RepostOptionsModalRoute( + eventReference: eventReference.toString(), + ).push(context); }, child: TextActionButton( icon: Assets.svg.iconBlockRepost.icon( diff --git a/lib/app/components/modal_action_button/modal_action_button.dart b/lib/app/components/modal_action_button/modal_action_button.dart index 976cf87bc..57e4379db 100644 --- a/lib/app/components/modal_action_button/modal_action_button.dart +++ b/lib/app/components/modal_action_button/modal_action_button.dart @@ -15,6 +15,7 @@ class ModalActionButton extends StatelessWidget { required this.label, required this.onTap, this.trailing, + this.labelStyle, super.key, }); @@ -22,6 +23,7 @@ class ModalActionButton extends StatelessWidget { final String label; final Widget? trailing; final VoidCallback onTap; + final TextStyle? labelStyle; @override Widget build(BuildContext context) { @@ -40,7 +42,10 @@ class ModalActionButton extends StatelessWidget { title: Row( children: [ Expanded( - child: Text(label, style: context.theme.appTextThemes.body), + child: Text( + label, + style: labelStyle ?? context.theme.appTextThemes.body, + ), ), if (trailing != null) ...[ SizedBox(width: 16.0.s), diff --git a/lib/app/exceptions/exceptions.dart b/lib/app/exceptions/exceptions.dart index 54819affb..44e19f6e0 100644 --- a/lib/app/exceptions/exceptions.dart +++ b/lib/app/exceptions/exceptions.dart @@ -227,3 +227,8 @@ class ForceUpdateCouldntLaunchUrlException extends IONException { class ForceUpdateFetchConfigException extends IONException { ForceUpdateFetchConfigException() : super(10047, 'Failed to get version config'); } + +class DeleteEntityUnsupportedTypeException extends IONException { + DeleteEntityUnsupportedTypeException() + : super(10048, 'Failed to delete entity, unsupported type'); +} diff --git a/lib/app/features/components/entities_list/components/article_list_item.dart b/lib/app/features/components/entities_list/components/article_list_item.dart index 16c7cee4a..d1fc07abc 100644 --- a/lib/app/features/components/entities_list/components/article_list_item.dart +++ b/lib/app/features/components/entities_list/components/article_list_item.dart @@ -18,7 +18,7 @@ class ArticleListItem extends ConsumerWidget { final eventReference = EventReference.fromIonConnectEntity(article); return Padding( - padding: EdgeInsets.only(top: 12.0.s, bottom: 12.0.s, right: 16.0.s), + padding: EdgeInsets.only(top: 12.0.s, bottom: 24.0.s, right: 16.0.s), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => diff --git a/lib/app/features/feed/providers/counters/replied_events_provider.c.dart b/lib/app/features/feed/providers/counters/replied_events_provider.c.dart index b30ccce99..42d2c3da9 100644 --- a/lib/app/features/feed/providers/counters/replied_events_provider.c.dart +++ b/lib/app/features/feed/providers/counters/replied_events_provider.c.dart @@ -5,58 +5,110 @@ import 'package:ion/app/features/auth/providers/auth_provider.c.dart'; import 'package:ion/app/features/feed/data/models/entities/post_data.c.dart'; import 'package:ion/app/features/ion_connect/model/event_reference.c.dart'; import 'package:ion/app/features/ion_connect/model/ion_connect_entity.dart'; -import 'package:ion/app/features/ion_connect/model/related_event.c.dart'; import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'replied_events_provider.c.g.dart'; @Riverpod(keepAlive: true) -Stream?> repliedEvents(Ref ref) async* { - final currentPubkey = await ref.watch(currentPubkeySelectorProvider.future); - - if (currentPubkey == null) { - yield {}; - } else { - final cache = ref.read(ionConnectCacheProvider); - var repliedIds = cache.values.fold>({}, (result, entry) { - final currentUserRepliedIds = _getCurrentUserRepliedIds(entry, currentPubkey: currentPubkey); - if (currentUserRepliedIds != null) { - result.addAll(currentUserRepliedIds); - } - return result; - }); +class RepliedEvents extends _$RepliedEvents { + final _deletedIds = {}; + + @override + Stream>?> build() async* { + final currentPubkey = await ref.watch(currentPubkeySelectorProvider.future); + + if (currentPubkey == null) { + yield {}; + } else { + var repliedMap = >{}; + + final cache = ref.read(ionConnectCacheProvider); + repliedMap = _buildInitialMap(cache, currentPubkey); + yield repliedMap; + + await for (final entity in ref.watch(ionConnectCacheStreamProvider)) { + if (entity case final PostEntity post) { + final parentId = post.data.parentEvent?.eventId; + if (parentId == null) continue; + + final currentUserRepliedIds = + _getCurrentUserRepliedIds(post, currentPubkey: currentPubkey); - yield repliedIds; + if (currentUserRepliedIds != null) { + final validIds = + currentUserRepliedIds.where((id) => !_deletedIds.contains(id)).toList(); - await for (final entity in ref.watch(ionConnectCacheStreamProvider)) { - final currentUserRepliedIds = _getCurrentUserRepliedIds(entity, currentPubkey: currentPubkey); - if (currentUserRepliedIds != null) { - yield repliedIds = {...repliedIds, ...currentUserRepliedIds}; + final updatedMap = Map>.from(repliedMap); + + if (validIds.isEmpty) { + updatedMap.remove(parentId); + } else { + updatedMap[parentId] = validIds; + } + + repliedMap = updatedMap; + yield repliedMap; + } + } } } } -} -List? _getCurrentUserRepliedIds(IonConnectEntity entity, {required String currentPubkey}) { - if (entity.masterPubkey != currentPubkey || entity is! PostEntity) { - return null; - } + void removeReply(String parentId, String replyId) { + _deletedIds.add(replyId); + + final currentMap = state.valueOrNull; + if (currentMap != null && currentMap.containsKey(parentId)) { + final updatedReplies = Map>.from(currentMap); + updatedReplies[parentId] = updatedReplies[parentId]!.where((id) => id != replyId).toList(); - final relatedEvents = entity.data.relatedEvents; + if (updatedReplies[parentId]!.isEmpty) { + updatedReplies.remove(parentId); + } - if (relatedEvents == null) { - return null; + state = AsyncData(updatedReplies); + } } +} + +Map> _buildInitialMap( + Map cache, + String currentPubkey, +) { + return cache.values.fold>>({}, (result, entry) { + if (entry case final PostEntity post) { + final currentUserRepliedIds = _getCurrentUserRepliedIds(post, currentPubkey: currentPubkey); + final parentId = post.data.parentEvent?.eventId; + if (currentUserRepliedIds != null && parentId != null) { + if (!result.containsKey(parentId)) { + result[parentId] = []; + } + final newIds = currentUserRepliedIds.where((id) => !result[parentId]!.contains(id)); + result[parentId] = [...result[parentId]!, ...newIds]; + } + } + return result; + }); +} - return [ - for (final event in relatedEvents) - if (event.marker == RelatedEventMarker.reply || event.marker == RelatedEventMarker.root) - event.eventId, - ]; +List? _getCurrentUserRepliedIds(IonConnectEntity entity, {required String currentPubkey}) { + if (entity case final PostEntity post when post.masterPubkey == currentPubkey) { + if (post.data.parentEvent != null) { + return [post.id]; + } + } + return null; } @riverpod bool isReplied(Ref ref, EventReference eventReference) { - return ref.watch(repliedEventsProvider).valueOrNull?.contains(eventReference.eventId) ?? false; + final repliedMap = ref.watch(repliedEventsProvider).valueOrNull; + final replyIds = repliedMap?[eventReference.eventId]; + + final deletedIds = ref.read(repliedEventsProvider.notifier)._deletedIds; + final validReplyIds = replyIds?.where((id) => !deletedIds.contains(id)).toList(); + final hasReply = validReplyIds?.isNotEmpty ?? false; + + return hasReply; } diff --git a/lib/app/features/feed/providers/counters/replies_count_provider.c.dart b/lib/app/features/feed/providers/counters/replies_count_provider.c.dart index d7675a88f..45006bbd1 100644 --- a/lib/app/features/feed/providers/counters/replies_count_provider.c.dart +++ b/lib/app/features/feed/providers/counters/replies_count_provider.c.dart @@ -11,22 +11,22 @@ part 'replies_count_provider.c.g.dart'; class RepliesCount extends _$RepliesCount { @override int build(EventReference eventReference) { - final entity = ref.watch( - ionConnectCacheProvider.select( - cacheSelector( - EventCountResultEntity.cacheKeyBuilder( - key: eventReference.eventId, - type: EventCountResultType.replies, - ), - ), - ), - ); + final cacheCount = ref + .watch( + ionConnectCacheProvider.select( + cacheSelector( + EventCountResultEntity.cacheKeyBuilder( + key: eventReference.eventId, + type: EventCountResultType.replies, + ), + ), + ), + ) + ?.data + .content as int? ?? + 0; - if (entity == null) { - return 0; - } - - return entity.data.content as int; + return cacheCount; } void addOne() { diff --git a/lib/app/features/feed/providers/delete_entity_provider.c.dart b/lib/app/features/feed/providers/delete_entity_provider.c.dart new file mode 100644 index 000000000..5864a7037 --- /dev/null +++ b/lib/app/features/feed/providers/delete_entity_provider.c.dart @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/exceptions/exceptions.dart'; +import 'package:ion/app/features/feed/data/models/entities/article_data.c.dart'; +import 'package:ion/app/features/feed/data/models/entities/post_data.c.dart'; +import 'package:ion/app/features/feed/data/models/entities/repost_data.c.dart'; +import 'package:ion/app/features/feed/data/models/generic_repost.c.dart'; +import 'package:ion/app/features/feed/providers/counters/replied_events_provider.c.dart'; +import 'package:ion/app/features/feed/providers/counters/replies_count_provider.c.dart'; +import 'package:ion/app/features/feed/providers/feed_posts_data_source_provider.c.dart'; +import 'package:ion/app/features/feed/providers/replies_provider.c.dart'; +import 'package:ion/app/features/ion_connect/model/deletion_request.c.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.c.dart'; +import 'package:ion/app/features/ion_connect/providers/entities_paged_data_provider.c.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_notifier.c.dart'; +import 'package:ion/app/features/user/providers/user_articles_data_source_provider.c.dart'; +import 'package:ion/app/features/user/providers/user_posts_data_source_provider.c.dart'; +import 'package:ion/app/features/user/providers/user_replies_data_source_provider.c.dart'; +import 'package:ion/app/features/user/providers/user_videos_data_source_provider.c.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'delete_entity_provider.c.g.dart'; + +@riverpod +Future deleteEntity( + Ref ref, + CacheableEntity entity, +) async { + await _deleteFromServer(ref, entity); + await _deleteFromDataSources(ref, entity); +} + +Future _deleteFromServer(Ref ref, CacheableEntity entity) async { + final entityKind = switch (entity) { + PostEntity() => PostEntity.kind, + ArticleEntity() => ArticleEntity.kind, + RepostEntity() => RepostEntity.kind, + GenericRepostEntity() => GenericRepostEntity.kind, + _ => throw DeleteEntityUnsupportedTypeException(), + }; + + final deletionRequest = DeletionRequest( + events: [EventToDelete(eventId: entity.id, kind: entityKind)], + ); + await ref.read(ionConnectNotifierProvider.notifier).sendEntityData(deletionRequest, cache: false); +} + +Future _deleteFromDataSources(Ref ref, CacheableEntity entity) async { + if (entity case final PostEntity post when post.data.parentEvent != null) { + await _deleteReply(ref, post); + } else if (entity case final ArticleEntity _) { + await _deleteArticle(ref, entity); + } else { + await _deletePost(ref, entity); + } +} + +Future _deleteReply(Ref ref, PostEntity post) async { + final dataSource = ref.watch(userRepliesDataSourceProvider(post.masterPubkey)) ?? []; + + final parentId = post.data.parentEvent!.eventId; + + ref + .read( + repliesCountProvider( + EventReference( + eventId: parentId, + pubkey: post.data.parentEvent!.pubkey, + ), + ).notifier, + ) + .removeOne(); + + await ref + .read( + repliesProvider( + EventReference( + eventId: parentId, + pubkey: post.data.parentEvent!.pubkey, + ), + ).notifier, + ) + .deleteReply(entity: post); + + ref.read(repliedEventsProvider.notifier).removeReply(parentId, post.id); + + await _deleteFromDataSource(ref, dataSource, post); + ref.read(ionConnectCacheProvider.notifier).remove(post.cacheKey); +} + +Future _deleteArticle(Ref ref, CacheableEntity entity) async { + final userArticlesDataSource = ref.watch(userArticlesDataSourceProvider(entity.masterPubkey)); + final feedDataSources = ref.watch(feedPostsDataSourceProvider) ?? []; + + await _deleteFromDataSource(ref, userArticlesDataSource ?? [], entity); + await _deleteFromDataSource(ref, feedDataSources, entity); + ref.read(ionConnectCacheProvider.notifier).remove(entity.cacheKey); +} + +Future _deletePost(Ref ref, CacheableEntity entity) async { + final userVideosDataSource = ref.watch(userVideosDataSourceProvider(entity.masterPubkey)); + final userPostsDataSource = ref.watch(userPostsDataSourceProvider(entity.masterPubkey)); + final feedDataSources = ref.watch(feedPostsDataSourceProvider) ?? []; + + await _deleteFromDataSource(ref, userVideosDataSource ?? [], entity); + await _deleteFromDataSource(ref, userPostsDataSource ?? [], entity); + await _deleteFromDataSource(ref, feedDataSources, entity); + ref.read(ionConnectCacheProvider.notifier).remove(entity.cacheKey); +} + +Future _deleteFromDataSource( + Ref ref, + List dataSource, + CacheableEntity entity, +) async { + await ref.read(entitiesPagedDataProvider(dataSource).notifier).deleteEntity(entity); +} diff --git a/lib/app/features/feed/providers/replies_provider.c.dart b/lib/app/features/feed/providers/replies_provider.c.dart index 9d344d30e..dfd76615e 100644 --- a/lib/app/features/feed/providers/replies_provider.c.dart +++ b/lib/app/features/feed/providers/replies_provider.c.dart @@ -22,6 +22,10 @@ class Replies extends _$Replies { .where((entity) => _isReply(entity, eventReference)) .distinct() .listen((entity) { + if (state?.data.items?.any((e) => e.id == entity.id) ?? false) { + return; + } + state = state?.copyWith.data(items: {entity, ...state?.data.items ?? {}}); }); ref.onDispose(subscription.cancel); @@ -32,4 +36,21 @@ class Replies extends _$Replies { bool _isReply(IonConnectEntity entity, EventReference parentEventReference) { return entity is PostEntity && entity.data.parentEvent?.eventId == parentEventReference.eventId; } + + Future deleteReply({ + required CacheableEntity entity, + }) async { + final currentState = state; + if (currentState != null) { + final updatedItems = + currentState.data.items?.where((item) => item.id != entity.id).toSet() ?? {}; + + state = currentState.copyWith( + data: currentState.data.copyWith( + items: updatedItems, + pagination: currentState.data.pagination, + ), + ); + } + } } diff --git a/lib/app/features/feed/providers/repost_entity_provider.c.dart b/lib/app/features/feed/providers/repost_entity_provider.c.dart new file mode 100644 index 000000000..ced29500a --- /dev/null +++ b/lib/app/features/feed/providers/repost_entity_provider.c.dart @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/feed/data/models/entities/repost_data.c.dart'; +import 'package:ion/app/features/ion_connect/model/event_reference.c.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'repost_entity_provider.c.g.dart'; + +@riverpod +CacheableEntity? repostEntity(Ref ref, EventReference eventReference) { + final cache = ref.read(ionConnectCacheProvider); + + return cache.values.firstWhereOrNull( + (entity) => entity is RepostEntity && entity.data.eventId == eventReference.eventId, + ); +} diff --git a/lib/app/features/feed/views/components/article/article.dart b/lib/app/features/feed/views/components/article/article.dart index 40380cdc0..82ccd10de 100644 --- a/lib/app/features/feed/views/components/article/article.dart +++ b/lib/app/features/feed/views/components/article/article.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/components/skeleton/skeleton.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.c.dart'; import 'package:ion/app/features/components/entities_list/components/bookmark_button/bookmark_button.dart'; import 'package:ion/app/features/feed/data/models/entities/article_data.c.dart'; import 'package:ion/app/features/feed/views/components/article/components/article_footer/article_footer.dart'; import 'package:ion/app/features/feed/views/components/article/components/article_image/article_image.dart'; +import 'package:ion/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart'; import 'package:ion/app/features/feed/views/components/post/post_skeleton.dart'; import 'package:ion/app/features/feed/views/components/user_info/user_info.dart'; import 'package:ion/app/features/feed/views/components/user_info_menu/user_info_menu.dart'; @@ -28,9 +30,8 @@ class Article extends ConsumerWidget { return Article(eventReference: eventReference, showActionButtons: false); } - final bool showActionButtons; - final EventReference eventReference; + final bool showActionButtons; @override Widget build(BuildContext context, WidgetRef ref) { @@ -42,6 +43,9 @@ class Article extends ConsumerWidget { return const Skeleton(child: PostSkeleton()); } + final isOwnedByCurrentUser = + ref.watch(isCurrentUserSelectorProvider(articleEntity.masterPubkey)); + return ColoredBox( color: context.theme.appColors.onPrimaryAccent, child: IntrinsicHeight( @@ -69,7 +73,12 @@ class Article extends ConsumerWidget { ? Row( children: [ BookmarkButton(eventReference: eventReference), - UserInfoMenu(pubkey: eventReference.pubkey), + if (isOwnedByCurrentUser) + DeleteFeedItemMenu( + entity: articleEntity, + ) + else + UserInfoMenu(pubkey: eventReference.pubkey), ], ) : null, diff --git a/lib/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart b/lib/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart new file mode 100644 index 000000000..4b2539c34 --- /dev/null +++ b/lib/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/overlay_menu/overlay_menu.dart'; +import 'package:ion/app/components/overlay_menu/overlay_menu_container.dart'; +import 'package:ion/app/extensions/asset_gen_image.dart'; +import 'package:ion/app/extensions/build_context.dart'; +import 'package:ion/app/extensions/num.dart'; +import 'package:ion/app/extensions/theme_data.dart'; +import 'package:ion/app/features/feed/providers/delete_entity_provider.c.dart'; +import 'package:ion/app/features/feed/views/components/user_info_menu/user_info_menu_item.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class DeleteFeedItemMenu extends ConsumerWidget { + const DeleteFeedItemMenu({ + required this.entity, + this.iconColor, + this.onDelete, + super.key, + }); + + static double get iconSize => 20.0.s; + + final CacheableEntity entity; + final Color? iconColor; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return OverlayMenu( + menuBuilder: (closeMenu) => Column( + children: [ + OverlayMenuContainer( + child: UserInfoMenuItem( + label: context.i18n.post_menu_delete, + labelColor: context.theme.appColors.attentionRed, + icon: Assets.svg.iconBlockDelete.icon( + size: iconSize, + color: context.theme.appColors.attentionRed, + ), + onPressed: () async { + closeMenu(); + await ref.read(deleteEntityProvider(entity).future); + onDelete?.call(); + }, + ), + ), + ], + ), + child: Assets.svg.iconMorePopup.icon( + color: iconColor ?? context.theme.appColors.onTertararyBackground, + ), + ); + } +} diff --git a/lib/app/features/feed/views/components/post/post.dart b/lib/app/features/feed/views/components/post/post.dart index 9b6eaa4e2..e584933e8 100644 --- a/lib/app/features/feed/views/components/post/post.dart +++ b/lib/app/features/feed/views/components/post/post.dart @@ -6,9 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/components/counter_items_footer/counter_items_footer.dart'; import 'package:ion/app/components/skeleton/skeleton.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.c.dart'; import 'package:ion/app/features/feed/data/models/entities/article_data.c.dart'; import 'package:ion/app/features/feed/data/models/entities/post_data.c.dart'; import 'package:ion/app/features/feed/views/components/article/article.dart'; +import 'package:ion/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart'; import 'package:ion/app/features/feed/views/components/post/components/post_body/post_body.dart'; import 'package:ion/app/features/feed/views/components/post/post_skeleton.dart'; import 'package:ion/app/features/feed/views/components/quoted_entity_frame/quoted_entity_frame.dart'; @@ -24,6 +26,7 @@ class Post extends ConsumerWidget { this.header, this.footer, this.showParent = false, + this.onDelete, super.key, }); @@ -31,6 +34,7 @@ class Post extends ConsumerWidget { final bool showParent; final Widget? header; final Widget? footer; + final VoidCallback? onDelete; @override Widget build(BuildContext context, WidgetRef ref) { @@ -42,6 +46,8 @@ class Post extends ConsumerWidget { return const Skeleton(child: PostSkeleton()); } + final isOwnedByCurrentUser = ref.watch(isCurrentUserSelectorProvider(postEntity.masterPubkey)); + final framedEvent = _getFramedEventReference(postEntity); return Column( @@ -51,12 +57,20 @@ class Post extends ConsumerWidget { header ?? UserInfo( pubkey: eventReference.pubkey, - trailing: UserInfoMenu(pubkey: eventReference.pubkey), + trailing: isOwnedByCurrentUser + ? DeleteFeedItemMenu( + entity: postEntity, + onDelete: onDelete, + ) + : UserInfoMenu(pubkey: eventReference.pubkey), ), SizedBox(height: 10.0.s), PostBody(postEntity: postEntity), if (framedEvent != null) _FramedEvent(eventReference: framedEvent), - footer ?? CounterItemsFooter(eventReference: eventReference), + footer ?? + CounterItemsFooter( + eventReference: eventReference, + ), ], ); } diff --git a/lib/app/features/feed/views/components/user_info_menu/user_info_menu_item.dart b/lib/app/features/feed/views/components/user_info_menu/user_info_menu_item.dart index c638249b3..2e83afa50 100644 --- a/lib/app/features/feed/views/components/user_info_menu/user_info_menu_item.dart +++ b/lib/app/features/feed/views/components/user_info_menu/user_info_menu_item.dart @@ -8,12 +8,14 @@ class UserInfoMenuItem extends StatelessWidget { required this.label, required this.icon, required this.onPressed, + this.labelColor, super.key, }); final String label; final Widget icon; final VoidCallback onPressed; + final Color? labelColor; @override Widget build(BuildContext context) { @@ -34,7 +36,9 @@ class UserInfoMenuItem extends StatelessWidget { label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: textStyles.subtitle3.copyWith(color: colors.primaryText), + style: textStyles.subtitle3.copyWith( + color: labelColor ?? colors.primaryText, + ), ), ), icon, diff --git a/lib/app/features/feed/views/pages/article_details_page/article_details_page.dart b/lib/app/features/feed/views/pages/article_details_page/article_details_page.dart index fbca65040..ce47109ac 100644 --- a/lib/app/features/feed/views/pages/article_details_page/article_details_page.dart +++ b/lib/app/features/feed/views/pages/article_details_page/article_details_page.dart @@ -96,7 +96,7 @@ class ArticleDetailsPage extends HookConsumerWidget { Container(color: context.theme.appColors.primaryBackground, height: 8.0.s), SizedBox(height: 20.0.s), ScreenSideOffset.small( - child: UserBiography(pubkey: articleEntity.masterPubkey), + child: UserBiography(entity: articleEntity), ), SizedBox(height: 4.0.s), const ArticleDetailsTopics(), diff --git a/lib/app/features/feed/views/pages/article_details_page/components/user_biography.dart b/lib/app/features/feed/views/pages/article_details_page/components/user_biography.dart index cc0fd7a7f..0161e34bd 100644 --- a/lib/app/features/feed/views/pages/article_details_page/components/user_biography.dart +++ b/lib/app/features/feed/views/pages/article_details_page/components/user_biography.dart @@ -1,19 +1,26 @@ // SPDX-License-Identifier: ice License 1.0 import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/auth/providers/auth_provider.c.dart'; import 'package:ion/app/features/components/user/user_about/user_about.dart'; import 'package:ion/app/features/components/user/user_info_summary/user_info_summary.dart'; +import 'package:ion/app/features/feed/views/components/delete_feed_item_menu/delete_feed_item_menu.dart'; import 'package:ion/app/features/feed/views/components/user_info/user_info.dart'; import 'package:ion/app/features/feed/views/components/user_info_menu/user_info_menu.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; -class UserBiography extends StatelessWidget { - const UserBiography({required this.pubkey, super.key}); +class UserBiography extends ConsumerWidget { + const UserBiography({required this.entity, super.key}); - final String pubkey; + final CacheableEntity entity; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final isOwnedByCurrentUser = ref.watch(isCurrentUserSelectorProvider(entity.masterPubkey)); + return Container( alignment: Alignment.topLeft, decoration: BoxDecoration( @@ -30,13 +37,20 @@ class UserBiography extends StatelessWidget { child: Column( children: [ UserInfo( - pubkey: pubkey, - trailing: UserInfoMenu(pubkey: pubkey), + pubkey: entity.masterPubkey, + trailing: isOwnedByCurrentUser + ? DeleteFeedItemMenu( + entity: entity, + onDelete: () { + context.pop(); + }, + ) + : UserInfoMenu(pubkey: entity.masterPubkey), ), SizedBox(height: 12.0.s), - UserAbout(pubkey: pubkey), + UserAbout(pubkey: entity.masterPubkey), SizedBox(height: 12.0.s), - UserInfoSummary(pubkey: pubkey), + UserInfoSummary(pubkey: entity.masterPubkey), ], ), ); diff --git a/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart b/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart index 5f302f2f1..1540e9450 100644 --- a/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart +++ b/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart @@ -1,6 +1,7 @@ // SPDX-License-Identifier: ice License 1.0 import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; import 'package:ion/app/components/separated/separator.dart'; @@ -40,7 +41,12 @@ class PostDetailsPage extends ConsumerWidget { slivers: [ SliverToBoxAdapter( child: ScreenSideOffset.small( - child: Post(eventReference: eventReference), + child: Post( + eventReference: eventReference, + onDelete: () { + context.pop(); + }, + ), ), ), SliverToBoxAdapter(child: FeedListSeparator()), diff --git a/lib/app/features/feed/views/pages/repost_options_modal/repost_option_action.dart b/lib/app/features/feed/views/pages/repost_options_modal/repost_option_action.dart index 170ffb4e6..b936e70b7 100644 --- a/lib/app/features/feed/views/pages/repost_options_modal/repost_option_action.dart +++ b/lib/app/features/feed/views/pages/repost_options_modal/repost_option_action.dart @@ -6,21 +6,31 @@ import 'package:ion/generated/assets.gen.dart'; enum RepostOptionAction { repost, + undoRepost, quotePost; String getLabel(BuildContext context) => switch (this) { RepostOptionAction.repost => context.i18n.feed_repost, + RepostOptionAction.undoRepost => context.i18n.feed_undo_repost, RepostOptionAction.quotePost => context.i18n.feed_quote, }; + Color getLabelColor(BuildContext context) => switch (this) { + RepostOptionAction.repost => context.theme.appColors.primaryAccent, + RepostOptionAction.undoRepost => context.theme.appColors.attentionRed, + RepostOptionAction.quotePost => context.theme.appColors.primaryAccent, + }; + Color getIconColor(BuildContext context) => switch (this) { RepostOptionAction.repost => context.theme.appColors.primaryAccent, + RepostOptionAction.undoRepost => context.theme.appColors.attentionRed, RepostOptionAction.quotePost => context.theme.appColors.primaryAccent, }; Widget getIcon(BuildContext context) { final icon = switch (this) { RepostOptionAction.repost => Assets.svg.iconFeedRepost, + RepostOptionAction.undoRepost => Assets.svg.iconFeedRepost, RepostOptionAction.quotePost => Assets.svg.iconFeedQuote, }; diff --git a/lib/app/features/feed/views/pages/repost_options_modal/repost_options_modal.dart b/lib/app/features/feed/views/pages/repost_options_modal/repost_options_modal.dart index 7272d1650..d0ebdf03b 100644 --- a/lib/app/features/feed/views/pages/repost_options_modal/repost_options_modal.dart +++ b/lib/app/features/feed/views/pages/repost_options_modal/repost_options_modal.dart @@ -10,9 +10,13 @@ import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart'; import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; import 'package:ion/app/components/separated/separated_column.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/feed/providers/counters/reposted_events_provider.c.dart'; +import 'package:ion/app/features/feed/providers/delete_entity_provider.c.dart'; +import 'package:ion/app/features/feed/providers/repost_entity_provider.c.dart'; import 'package:ion/app/features/feed/providers/repost_notifier.c.dart'; import 'package:ion/app/features/feed/views/pages/repost_options_modal/repost_option_action.dart'; import 'package:ion/app/features/ion_connect/model/event_reference.c.dart'; +import 'package:ion/app/features/ion_connect/providers/ion_connect_cache.c.dart'; import 'package:ion/app/router/app_routes.c.dart'; import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart'; import 'package:ion/app/router/components/navigation_app_bar/navigation_close_button.dart'; @@ -31,9 +35,14 @@ class RepostOptionsModal extends HookConsumerWidget { ref.displayErrors(repostNotifierProvider); final selectedAction = useState(null); - + final isReposted = ref.watch(isRepostedProvider(eventReference)); final repostLoading = ref.watch(repostNotifierProvider).isLoading; + final actions = [ + if (isReposted) RepostOptionAction.undoRepost else RepostOptionAction.repost, + RepostOptionAction.quotePost, + ]; + return SheetContent( body: SingleChildScrollView( child: Column( @@ -50,27 +59,40 @@ class RepostOptionsModal extends HookConsumerWidget { separator: SizedBox(height: 9.0.s), mainAxisSize: MainAxisSize.min, children: [ - for (final option in RepostOptionAction.values) + for (final option in actions) ModalActionButton( icon: (repostLoading && selectedAction.value == option) ? const IONLoadingIndicator(type: IndicatorType.dark) : option.getIcon(context), label: option.getLabel(context), + labelStyle: context.theme.appTextThemes.body.copyWith( + color: option.getLabelColor(context), + ), onTap: () async { selectedAction.value = option; - if (option == RepostOptionAction.repost) { - await ref - .read(repostNotifierProvider.notifier) - .repost(eventReference: eventReference); - if (!ref.read(repostNotifierProvider).hasError) { - if (context.mounted) { + switch (option) { + case RepostOptionAction.repost: + await ref + .read(repostNotifierProvider.notifier) + .repost(eventReference: eventReference); + if (!ref.read(repostNotifierProvider).hasError && context.mounted) { context.pop(); } - } - } else if (option == RepostOptionAction.quotePost) { - CreatePostRoute(quotedEvent: eventReference.toString()).go(context); + + case RepostOptionAction.quotePost: + CreatePostRoute(quotedEvent: eventReference.toString()).go(context); + case RepostOptionAction.undoRepost: + selectedAction.value = option; + final repostEntity = ref.read(repostEntityProvider(eventReference)); + + if (repostEntity case final CacheableEntity entity) { + await ref.read(deleteEntityProvider(entity).future); + if (context.mounted) { + context.pop(); + } + } + selectedAction.value = null; } - selectedAction.value = null; }, ), ], diff --git a/lib/app/features/ion_connect/providers/entities_paged_data_provider.c.dart b/lib/app/features/ion_connect/providers/entities_paged_data_provider.c.dart index 2679c78da..1eee03175 100644 --- a/lib/app/features/ion_connect/providers/entities_paged_data_provider.c.dart +++ b/lib/app/features/ion_connect/providers/entities_paged_data_provider.c.dart @@ -75,6 +75,18 @@ class EntitiesPagedData extends _$EntitiesPagedData { ); } + Future deleteEntity(IonConnectEntity postEntity) async { + final currentState = state; + if (currentState == null) return; + + final oldItems = currentState.data.items ?? {}; + final updatedItems = oldItems.where((entity) => entity.id != postEntity.id).toSet(); + + state = currentState.copyWith( + data: currentState.data.copyWith(items: updatedItems), + ); + } + Future> _fetchEntitiesFromDataSource( EntitiesDataSource dataSource, ) async { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 05143c8ed..211201e89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -406,6 +406,7 @@ "feed_modal_article": "Article", "feed_repost_type": "Type", "feed_repost": "Repost", + "feed_undo_repost": "Undo repost", "feed_someone_reposted": "{someone} reposted", "feed_quote": "Quote", "feed_write_comment": "Write comment", @@ -475,6 +476,7 @@ "post_menu_block_nickname": "Block @{nickname}", "post_menu_unblock_nickname": "Unblock @{nickname}", "post_menu_report_post": "Report post", + "post_menu_delete": "Delete", "protect_account_header_security": "Security", "protect_account_title_secure_account": "Secure your account", "protect_account_description_secure_account": "Securing your account ensures you never lose access to your data and funds",