diff --git a/Shared/Account/AccountView.swift b/Shared/Account/AccountView.swift index 5843048..fe22f19 100644 --- a/Shared/Account/AccountView.swift +++ b/Shared/Account/AccountView.swift @@ -1,40 +1,40 @@ import SwiftUI struct AccountView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling var body: some View { if model.account.isLoggedIn { HStack { Spacer() AccountLogoutView() .withErrorHandling() Spacer() } .padding() } else { AccountLoginView() .withErrorHandling() .padding(.top) } EmptyView() .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError) + self.errorHandling.handle(error: AppError.genericError("")) } model.hasError = false } } } } struct AccountLogin_Previews: PreviewProvider { static var previews: some View { AccountView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/ErrorHandling/ErrorConstants.swift b/Shared/ErrorHandling/ErrorConstants.swift index eb1f037..2962246 100644 --- a/Shared/ErrorHandling/ErrorConstants.swift +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -1,150 +1,154 @@ import Foundation // MARK: - Network Errors enum NetworkError: Error { case noConnectionError } extension NetworkError: LocalizedError { public var errorDescription: String? { switch self { case .noConnectionError: return NSLocalizedString( "There is no internet connection at the moment. Please reconnect or try again later.", comment: "" ) } } } // MARK: - Keychain Errors enum KeychainError: Error { case couldNotStoreAccessToken case couldNotPurgeAccessToken case couldNotFetchAccessToken } extension KeychainError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotStoreAccessToken: return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") case .couldNotPurgeAccessToken: return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") case .couldNotFetchAccessToken: return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") } } } // MARK: - Account Errors enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound case invalidServerURL case unknownLoginError case genericAuthError } extension AccountError: LocalizedError { public var errorDescription: String? { switch self { case .serverNotFound: return NSLocalizedString( "The server could not be found. Please check the information you've entered and try again.", comment: "" ) case .invalidPassword: return NSLocalizedString( "Invalid password. Please check that you've entered your password correctly and try logging in again.", comment: "" ) case .usernameNotFound: return NSLocalizedString( "Username not found. Did you use your email address by mistake?", comment: "" ) case .invalidServerURL: return NSLocalizedString( "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length comment: "" ) case .genericAuthError: return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") case .unknownLoginError: return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") } } } // MARK: - Local Store Errors enum LocalStoreError: Error { case couldNotSaveContext case couldNotFetchCollections case couldNotFetchPosts(String) case couldNotPurgePublishedPosts case couldNotPurgeCollections case couldNotLoadStore(String) case couldNotMigrateStore(String) case couldNotDeleteStoreAfterMigration(String) case genericError(String) } extension LocalStoreError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotSaveContext: return NSLocalizedString("Error saving context", comment: "") case .couldNotFetchCollections: return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") case .couldNotFetchPosts(let postFilter): if postFilter.isEmpty { return NSLocalizedString("Failed to fetch posts from local store.", comment: "") } else { return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") } case .couldNotPurgePublishedPosts: return NSLocalizedString("Failed to purge published posts from local store.", comment: "") case .couldNotPurgeCollections: return NSLocalizedString("Failed to purge cached collections", comment: "") case .couldNotLoadStore(let errorDescription): return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") case .couldNotMigrateStore(let errorDescription): return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") case .couldNotDeleteStoreAfterMigration(let errorDescription): return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") case .genericError(let customContent): if customContent.isEmpty { return NSLocalizedString("Something went wrong accessing device storage", comment: "") } else { return NSLocalizedString(customContent, comment: "") } } } } // MARK: - Application Errors enum AppError: Error { case couldNotGetLoggedInClient case couldNotGetPostId - case genericError + case genericError(String) } extension AppError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotGetLoggedInClient: return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") case .couldNotGetPostId: return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") - case .genericError: - return NSLocalizedString("Something went wrong", comment: "") + case .genericError(let customContent): + if customContent.isEmpty { + return NSLocalizedString("Something went wrong", comment: "") + } else { + return NSLocalizedString(customContent, comment: "") + } } } } diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index 6d0d147..c1d19d9 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,257 +1,261 @@ import Foundation import WriteFreely extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() fetchUserPosts() do { try saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch { self.currentError = KeychainError.couldNotStoreAccessToken } } catch WFError.notFound { self.currentError = AccountError.usernameNotFound } catch WFError.unauthorized { self.currentError = AccountError.invalidPassword } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { self.currentError = AccountError.serverNotFound } else { self.currentError = error } } } func logoutHandler(result: Result) { do { _ = try result.get() do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() LocalStorageManager.standard.purgeUserCollections() self.posts.purgePublishedPosts() } } catch { - print(KeychainError.couldNotPurgeAccessToken.localizedDescription) + self.currentError = KeychainError.couldNotPurgeAccessToken } } catch WFError.notFound { // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its // logged-out state. do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() LocalStorageManager.standard.purgeUserCollections() self.posts.purgePublishedPosts() } } catch { - print(KeychainError.couldNotPurgeAccessToken.localizedDescription) + self.currentError = KeychainError.couldNotPurgeAccessToken } } catch { // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're // logged in, try calling the logout function again and see what we get. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties. if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let fetchedCollections = try result.get() for fetchedCollection in fetchedCollections { DispatchQueue.main.async { let localCollection = WFACollection(context: LocalStorageManager.standard.container.viewContext) localCollection.alias = fetchedCollection.alias localCollection.blogDescription = fetchedCollection.description localCollection.email = fetchedCollection.email localCollection.isPublic = fetchedCollection.isPublic ?? false localCollection.styleSheet = fetchedCollection.styleSheet localCollection.title = fetchedCollection.title localCollection.url = fetchedCollection.url } } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } let request = WFAPost.createFetchRequest() do { let locallyCachedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) do { var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue } let fetchedPosts = try result.get() for fetchedPost in fetchedPosts { if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) { DispatchQueue.main.async { managedPost.wasDeletedFromServer = false if let fetchedPostUpdatedDate = fetchedPost.updatedDate, let localPostUpdatedDate = managedPost.updatedDate { managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate - } else { print("Error: could not determine which copy of post is newer") } + } else { + self.currentError = AppError.genericError( + "Error updating post: could not determine which copy of post is newer." + ) + } postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) } } else { DispatchQueue.main.async { let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) self.importData(from: fetchedPost, into: managedPost) managedPost.collectionAlias = fetchedPost.collectionAlias managedPost.wasDeletedFromServer = false } } } DispatchQueue.main.async { for post in postsToDelete { post.wasDeletedFromServer = true } LocalStorageManager.standard.saveContext() } } catch { - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { - print("Error: Failed to fetch cached posts") + self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } func publishHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() // If this is an updated post, check it against postToUpdate. if let updatingPost = self.postToUpdate { importData(from: fetchedPost, into: updatingPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } else { // Otherwise if it's a newly-published post, find it in the local store. let request = WFAPost.createFetchRequest() let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body) if let fetchedPostTitle = fetchedPost.title { let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle) request.predicate = NSCompoundPredicate( andPredicateWithSubpredicates: [ matchTitlePredicate, matchBodyPredicate ] ) } else { request.predicate = matchBodyPredicate } do { let cachedPostsResults = try LocalStorageManager.standard.container.viewContext.fetch(request) guard let cachedPost = cachedPostsResults.first else { return } importData(from: fetchedPost, into: cachedPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print("Error: Failed to fetch cached posts") + self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } } catch { - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } func updateFromServerHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() guard let cachedPost = self.selectedPost else { return } importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } func movePostHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let succeeded = try result.get() if succeeded { if let post = selectedPost { updateFromServer(post: post) } else { return } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() } - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } private func importData(from fetchedPost: WFPost, into cachedPost: WFAPost) { cachedPost.appearance = fetchedPost.appearance cachedPost.body = fetchedPost.body cachedPost.createdDate = fetchedPost.createdDate cachedPost.language = fetchedPost.language cachedPost.postId = fetchedPost.postId cachedPost.rtl = fetchedPost.rtl ?? false cachedPost.slug = fetchedPost.slug cachedPost.status = PostStatus.published.rawValue cachedPost.title = fetchedPost.title ?? "" cachedPost.updatedDate = fetchedPost.updatedDate } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index f1e82a0..ca13ff5 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,88 +1,88 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling var body: some View { NavigationView { #if os(macOS) CollectionListView() .withErrorHandling() .toolbar { Button( action: { NSApp.keyWindow?.contentViewController?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) }, label: { Image(systemName: "sidebar.left") } ) .help("Toggle the sidebar's visibility.") Spacer() Button(action: { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { DispatchQueue.main.asyncAfter(deadline: .now()) { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } #else CollectionListView() .withErrorHandling() #endif #if os(macOS) ZStack { PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) .withErrorHandling() if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } #else PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) .withErrorHandling() #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) EmptyView() .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError) + self.errorHandling.handle(error: AppError.genericError("")) } model.hasError = false } } } .environmentObject(model) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index 2158aa4..16e1f45 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,186 +1,186 @@ import SwiftUI import Combine struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling @Environment(\.managedObjectContext) var managedObjectContext @State private var postCount: Int = 0 @State private var filteredListViewId: Int = 0 var selectedCollection: WFACollection? var showAllPosts: Bool #if os(iOS) private var frameHeight: CGFloat { var height: CGFloat = 50 let bottom = UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0 height += bottom return height } #endif var body: some View { #if os(iOS) ZStack(alignment: .bottom) { PostListFilteredView( collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount ) .id(self.filteredListViewId) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .toolbar { ToolbarItem(placement: .primaryAction) { // We have to add a Spacer as a sibling view to the Button in some kind of Stack, so that any // a11y modifiers are applied as expected: bug report filed as FB8956392. ZStack { Spacer() Button(action: { let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { self.model.showAllPosts = false self.model.selectedPost = managedPost } }, label: { ZStack { Image("does.not.exist") .accessibilityHidden(true) Image(systemName: "square.and.pencil") .accessibilityHidden(true) .imageScale(.large) // These modifiers compensate for the resizing .padding(.vertical, 12) // done to the Image (and the button tap target) .padding(.leading, 12) // by the SwiftUI layout system from adding a .padding(.trailing, 8) // Spacer in this ZStack (FB8956392). } .frame(maxWidth: .infinity, maxHeight: .infinity) }) .accessibilityLabel(Text("Compose")) .accessibilityHint(Text("Compose a new local draft")) } } } VStack { HStack(spacing: 0) { Button(action: { model.isPresentingSettingsView = true }, label: { Image(systemName: "gear") .padding(.vertical, 4) .padding(.horizontal, 8) }) .accessibilityLabel(Text("Settings")) .accessibilityHint(Text("Open the Settings sheet")) .sheet( isPresented: $model.isPresentingSettingsView, onDismiss: { model.isPresentingSettingsView = false }, content: { SettingsView() .environmentObject(model) } ) Spacer() Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") .foregroundColor(.secondary) Spacer() if model.isProcessingRequest { ProgressView() .padding(.vertical, 4) .padding(.horizontal, 8) } else { Button(action: { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } }, label: { Image(systemName: "arrow.clockwise") .padding(.vertical, 4) .padding(.horizontal, 8) }) .accessibilityLabel(Text("Refresh Posts")) .accessibilityHint(Text("Fetch changes from the server")) .disabled(!model.account.isLoggedIn) } } .padding(.top, 8) .padding(.horizontal, 8) Spacer() } .frame(height: frameHeight) .background(Color(UIColor.systemGray5)) .overlay(Divider(), alignment: .top) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in // We use this to invalidate and refresh the view, so that new posts created outside of the app (e.g., // in the action extension) show up. withAnimation { self.filteredListViewId += 1 } } } .ignoresSafeArea(.all, edges: .bottom) .onAppear { model.selectedCollection = selectedCollection model.showAllPosts = showAllPosts } .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError) + self.errorHandling.handle(error: AppError.genericError("")) } model.hasError = false } } #else PostListFilteredView( collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount ) .toolbar { ToolbarItemGroup(placement: .primaryAction) { if model.selectedPost != nil { ActivePostToolbarView(activePost: model.selectedPost!) } } } .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .onAppear { model.selectedCollection = selectedCollection model.showAllPosts = showAllPosts } .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { self.errorHandling.handle(error: AppError.genericError) } model.hasError = false } } #endif } } struct PostListView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return PostListView(showAllPosts: true) .environment(\.managedObjectContext, context) .environmentObject(model) } }