diff --git a/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift b/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift index fab837616..72a18da79 100644 --- a/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift +++ b/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift @@ -3,7 +3,7 @@ import Services import Views extension Snackbar { - static func show(message: String) { - NSNotification.operationSuccess(message: message) + static func show(message: String, undoAction: (() -> Void)? = nil) { + NSNotification.operationSuccess(message: message, undoAction: undoAction) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 325e015a8..c8b32a58e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -257,9 +257,6 @@ struct AnimatingCellHeight: AnimatableModifier { @EnvironmentObject var audioController: AudioController @Binding var prefersListLayout: Bool - - @State private var itemToRemove: LinkedItem? - @State private var confirmationShown = false @State private var showHideFeatureAlert = false @ObservedObject var viewModel: HomeFeedViewModel @@ -344,8 +341,7 @@ struct AnimatingCellHeight: AnimatableModifier { ) }) Button("Remove Item", role: .destructive) { - itemToRemove = item - confirmationShown = true + viewModel.removeLink(dataService: dataService, objectID: item.objectID) } if let author = item.author { Button( @@ -505,18 +501,6 @@ struct AnimatingCellHeight: AnimatableModifier { .padding(0) .listStyle(PlainListStyle()) .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) - .alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.", - isPresented: $confirmationShown) { - Button("Remove Item", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) - } - } - self.itemToRemove = nil - } - Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil } - } } .alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.", isPresented: $showHideFeatureAlert) { @@ -556,8 +540,7 @@ struct AnimatingCellHeight: AnimatableModifier { case .delete: return AnyView(Button( action: { - itemToRemove = item - confirmationShown = true + viewModel.removeLink(dataService: dataService, objectID: item.objectID) }, label: { Label("Delete", systemImage: "trash") @@ -581,8 +564,6 @@ struct AnimatingCellHeight: AnimatableModifier { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - @State private var itemToRemove: LinkedItem? - @State private var confirmationShown = false @State var isContextMenuOpen = false @ObservedObject var viewModel: HomeFeedViewModel @@ -594,8 +575,7 @@ struct AnimatingCellHeight: AnimatableModifier { case .toggleArchiveStatus: viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived) case .delete: - itemToRemove = item - confirmationShown = true + viewModel.removeLink(dataService: dataService, objectID: item.objectID) case .editLabels: viewModel.itemUnderLabelEdit = item case .editTitle: @@ -710,18 +690,6 @@ struct AnimatingCellHeight: AnimatableModifier { } } } - // swiftlint:disable:next line_length - .alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.", isPresented: $confirmationShown) { - Button("Delete Item", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) - } - } - self.itemToRemove = nil - } - Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil } - } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 07957a4da..0fbd892c3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -318,8 +318,22 @@ import Views } func removeLink(dataService: DataService, objectID: NSManagedObjectID) { - Snackbar.show(message: "Link removed") - dataService.removeLink(objectID: objectID) + let item = dataService.viewContext.object(with: objectID) as? LinkedItem + + if let item = item { + let itemID = item.unwrappedID + dataService.removeLink(objectID: objectID) + + Snackbar.show(message: "Link deleted", undoAction: { + Task { + if await dataService.undeleteItem(itemID: itemID) { + Snackbar.show(message: "Link undeleted") + } else { + Snackbar.show(message: "Error. Check trash to recover.") + } + } + }) + } } func getOrCreateLabel(dataService: DataService, named: String, color: String) -> LinkedItemLabel? { diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index a5b0bfd79..321280b39 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -62,7 +62,7 @@ struct InnerRootView: View { } } #endif - .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage) + .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage, undoAction: viewModel.snackBarUndoAction) // Schedule the dismissal every time we present the snackbar. .onChange(of: viewModel.showSnackbar) { newValue in if newValue { @@ -93,8 +93,9 @@ struct InnerRootView: View { #if os(iOS) .onReceive(NSNotification.operationSuccessPublisher) { notification in if let message = notification.userInfo?["message"] as? String { - viewModel.showSnackbar = true viewModel.snackbarMessage = message + viewModel.snackBarUndoAction = notification.userInfo?["undoAction"] as? (() -> Void) + viewModel.showSnackbar = true } } .onReceive(NSNotification.operationFailedPublisher) { notification in diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 48189bced..29c8fd948 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -19,6 +19,7 @@ public final class RootViewModel: ObservableObject { @Published var snackbarMessage: String? @Published var showSnackbar = false + @Published var snackBarUndoAction: (() -> Void)? @Published var showMiniPlayer = true public init() { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift new file mode 100644 index 000000000..74dfa797c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UndeleteItem.swift @@ -0,0 +1,65 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func undeleteItem(itemID: String) async -> Bool { + var itemUpdatedLocal = false + // If the item is still available locally, update its state + backgroundContext.performAndWait { + if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) { + linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue) + + do { + try backgroundContext.save() + itemUpdatedLocal = true + logger.debug("LinkedItem updated succesfully") + } catch { + backgroundContext.rollback() + logger.debug("Failed to update LinkedItem: \(error.localizedDescription)") + } + } + } + + // If we undeleted locally, but failed to sync the undelete, that is OK, because + // the item shouldn't be deleted server side. + return await syncServerUndeleteItem(itemID: itemID) || itemUpdatedLocal + } + + func syncServerUndeleteItem(itemID: String) async -> Bool { + enum MutationResult { + case saved(title: String) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + updatePageError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) }, + updatePageSuccess: .init { + .saved(title: try $0.updatedPage(selection: Selection.Article { try $0.title() })) + } + ) + } + + let mutation = Selection.Mutation { + try $0.updatePage( + input: .init(pageId: itemID, + state: OptionalArgument(Enums.ArticleSavingRequestStatus.succeeded)), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + try? await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { _ in + continuation.resume() + } + } + + let result = try? await loadLinkedItem(username: "me", itemID: itemID) + return result != nil + } +} diff --git a/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift b/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift index 2a94c20bd..96043501a 100644 --- a/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift +++ b/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift @@ -67,8 +67,10 @@ public extension NSNotification { ) } - static func operationSuccess(message: String) { - NotificationCenter.default.post(name: NSNotification.OperationSuccess, object: nil, userInfo: ["message": message]) + static func operationSuccess(message: String, undoAction: (() -> Void)?) { + NotificationCenter.default.post(name: NSNotification.OperationSuccess, + object: nil, + userInfo: ["message": message, "undoAction": undoAction as Any]) } static func operationFailed(message: String) { diff --git a/apple/OmnivoreKit/Sources/Views/SnackBar.swift b/apple/OmnivoreKit/Sources/Views/SnackBar.swift index a12d9ef5d..ed43c98c8 100644 --- a/apple/OmnivoreKit/Sources/Views/SnackBar.swift +++ b/apple/OmnivoreKit/Sources/Views/SnackBar.swift @@ -5,16 +5,20 @@ public struct Snackbar: View { private let presentingView: AnyView private let text: Text + private let undoAction: (() -> Void)? + @Environment(\.colorScheme) private var colorScheme: ColorScheme init( isShowing: Binding, presentingView: PresentingView, - text: Text + text: Text, + undoAction: (() -> Void)? ) where PresentingView: View { self._isShowing = isShowing self.presentingView = AnyView(presentingView) self.text = text + self.undoAction = undoAction } public var body: some View { @@ -29,6 +33,10 @@ public struct Snackbar: View { .font(.appCallout) .foregroundColor(self.colorScheme == .light ? .white : .appTextDefault) Spacer() + if let undoAction = undoAction { + Button("Undo", action: undoAction) + .font(.system(size: 16, weight: .bold)) + } } .padding() .frame(width: min(380, geometry.size.width * 0.96), height: 44) @@ -45,7 +53,7 @@ public struct Snackbar: View { } public extension View { - func snackBar(isShowing: Binding, message: String?) -> some View { - Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? "")) + func snackBar(isShowing: Binding, message: String?, undoAction: (() -> Void)?) -> some View { + Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? ""), undoAction: undoAction) } }