Add undelete

This commit is contained in:
Jackson Harper
2023-07-12 15:44:22 +08:00
parent e7c43a6a08
commit 55d24ee52d
8 changed files with 105 additions and 46 deletions

View File

@ -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)
}
}

View File

@ -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 }
}
}
}

View File

@ -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? {

View File

@ -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

View File

@ -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() {

View File

@ -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<MutationResult, Unions.UpdatePageResult> {
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
}
}

View File

@ -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) {

View File

@ -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<PresentingView>(
isShowing: Binding<Bool>,
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<Bool>, message: String?) -> some View {
Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? ""))
func snackBar(isShowing: Binding<Bool>, message: String?, undoAction: (() -> Void)?) -> some View {
Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? ""), undoAction: undoAction)
}
}