diff --git a/Shared/Extensions/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift index 1a574ab..47ec63e 100644 --- a/Shared/Extensions/WriteFreelyModel+API.swift +++ b/Shared/Extensions/WriteFreelyModel+API.swift @@ -1,182 +1,182 @@ import Foundation import WriteFreely extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } let secureProtocolPrefix = "https://" let insecureProtocolPrefix = "http://" var serverString = server.absoluteString // If there's neither an http or https prefix, prepend "https://" to the server string. if !(serverString.hasPrefix(secureProtocolPrefix) || serverString.hasPrefix(insecureProtocolPrefix)) { serverString = secureProtocolPrefix + serverString } // If the server string is prefixed with http, upgrade to https before attempting to login. if serverString.hasPrefix(insecureProtocolPrefix) { serverString = serverString.replacingOccurrences(of: insecureProtocolPrefix, with: secureProtocolPrefix) } isLoggingIn = true var serverURL = URL(string: serverString)! if !serverURL.path.isEmpty { serverURL.deleteLastPathComponent() } account.server = serverURL.absoluteString client = WFClient(for: serverURL) client?.login(username: username, password: password, completion: loginHandler) } func logout() { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { do { try purgeTokenFromKeychain(username: account.username, server: account.server) account.logout() } catch { self.currentError = KeychainError.couldNotPurgeAccessToken } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { self.currentError = AppError.couldNotGetLoggedInClient return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { self.currentError = AppError.couldNotGetLoggedInClient return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getPosts(completion: fetchUserPostsHandler) } func publish(post: WFAPost) { postToUpdate = nil if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { self.currentError = AppError.couldNotGetLoggedInClient return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } if post.language == nil { if #available(iOS 16, macOS 13, *) { if let languageCode = Locale.current.language.languageCode?.identifier { post.language = languageCode post.rtl = Locale.Language(identifier: languageCode).characterDirection == .rightToLeft } } else { if let languageCode = Locale.current.languageCode { post.language = languageCode post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } } } var wfPost = WFPost( body: post.body, title: post.title.isEmpty ? "" : post.title, appearance: post.appearance, language: post.language, rtl: post.rtl, createdDate: post.status == PostStatus.local.rawValue ? Date() : post.createdDate ) if let existingPostId = post.postId { // This is an existing post. postToUpdate = post wfPost.postId = post.postId loggedInClient.updatePost( postId: existingPostId, updatedPost: wfPost, completion: publishHandler ) } else { // This is a new local draft. loggedInClient.createPost( post: wfPost, in: post.collectionAlias, completion: publishHandler ) } } func updateFromServer(post: WFAPost) { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { self.currentError = AppError.couldNotGetLoggedInClient return } guard let postId = post.postId else { self.currentError = AppError.couldNotGetPostId return } // We're starting the network request. DispatchQueue.main.async { #if os(iOS) - self.selectedPost = post + self.navState.selectedPost = post #endif self.isProcessingRequest = true } loggedInClient.getPost(byId: postId, completion: updateFromServerHandler) } func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { if !hasNetworkConnection { self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { self.currentError = AppError.couldNotGetLoggedInClient return } guard let postId = post.postId else { self.currentError = AppError.couldNotGetPostId return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } - selectedPost = post + navState.selectedPost = post post.collectionAlias = newCollection?.alias loggedInClient.movePost(postId: postId, to: newCollection?.alias, completion: movePostHandler) } } diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index 8698977..beb186b 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,288 +1,288 @@ 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() do { try LocalStorageManager.standard.purgeUserCollections() try self.posts.purgePublishedPosts() } catch { self.currentError = error } } } catch { 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() do { try LocalStorageManager.standard.purgeUserCollections() try self.posts.purgePublishedPosts() } catch { self.currentError = error } } } catch { 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 { self.currentError = AppError.genericError(error.localizedDescription) } } // swiftlint:disable function_body_length 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 { self.currentError = AppError.genericError( "Error updating post: could not determine which copy of post is newer." ) } if managedPost.collectionAlias != fetchedPost.collectionAlias { // The post has been moved so we update the managed post's collectionAlias property. DispatchQueue.main.async { - if self.selectedPost == managedPost { - self.selectedPost = nil + if self.navState.selectedPost == managedPost { + self.navState.selectedPost = nil } managedPost.collectionAlias = fetchedPost.collectionAlias } } 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 { self.currentError = AppError.genericError(error.localizedDescription) } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } // swiftlint:enable function_body_length 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 { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } } catch { 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() #if os(iOS) - guard let cachedPost = self.selectedPost else { return } + guard let cachedPost = self.navState.selectedPost else { return } #else guard let cachedPost = self.editor.postToUpdate else { return } #endif importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() #if os(macOS) self.selectedPost = cachedPost #endif cachedPost.status = PostStatus.published.rawValue } } catch { 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 { + if let post = navState.selectedPost { updateFromServer(post: post) } else { return } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() } 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 5b9d89a..e67e145 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,112 +1,115 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling var body: some View { #if os(macOS) WFNavigation( collectionList: { CollectionListView() .withErrorHandling() .toolbar { if #available(macOS 13, *) { EmptyView() } else { 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.async { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } .frame(width: 200) }, postList: { ZStack { PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) .withErrorHandling() .frame(width: 300) if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } }, postDetail: { NoSelectedPostView(isConnected: $model.hasNetworkConnection) } ) .environmentObject(model) .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 } } #else WFNavigation( collectionList: { CollectionListView() .withErrorHandling() }, postList: { - PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) - .withErrorHandling() + PostListView( + selectedCollection: model.navState.selectedCollection, + showAllPosts: model.navState.showAllPosts + ) + .withErrorHandling() }, postDetail: { NoSelectedPostView(isConnected: $model.hasNetworkConnection) } ) .environmentObject(model) .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 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/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 2c3dab7..a5f9300 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,69 +1,69 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling @FetchRequest(sortDescriptors: []) var collections: FetchedResults @State var selectedCollection: WFACollection? var body: some View { List(selection: $selectedCollection) { if model.account.isLoggedIn { NavigationLink("All Posts", destination: PostListView(selectedCollection: nil, showAllPosts: true)) NavigationLink( model.account.server == "https://write.as" ? "Anonymous" : "Drafts", destination: PostListView(selectedCollection: nil, showAllPosts: false) ) Section(header: Text("Your Blogs")) { ForEach(collections, id: \.self) { collection in NavigationLink(destination: PostListView(selectedCollection: collection, showAllPosts: false), tag: collection, selection: $selectedCollection, label: { Text("\(collection.title)") }) } } } else { NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { Text("Drafts") } } } .navigationTitle( model.account.isLoggedIn ? "\(URL(string: model.account.server)?.host ?? "WriteFreely")" : "WriteFreely" ) .listStyle(SidebarListStyle()) - .onChange(of: model.selectedCollection) { collection in - model.selectedPost = nil + .onChange(of: model.navState.selectedCollection) { collection in + model.navState.selectedPost = nil if collection != model.editor.fetchSelectedCollectionFromAppStorage() { self.model.editor.selectedCollectionURL = collection?.objectID.uriRepresentation() } } - .onChange(of: model.showAllPosts) { value in - model.selectedPost = nil + .onChange(of: model.navState.showAllPosts) { value in + model.navState.selectedPost = nil if value != model.editor.showAllPostsFlag { - self.model.editor.showAllPostsFlag = model.showAllPosts + self.model.editor.showAllPostsFlag = model.navState.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 } } } } struct CollectionListView_LoggedOutPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return CollectionListView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index c6fda4c..e961b51 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,92 +1,92 @@ import SwiftUI import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" case mono = "Hack-Regular" case serif = "Lora-Regular" } struct PostEditorModel { @AppStorage(WFDefaults.showAllPostsFlag, store: UserDefaults.shared) var showAllPostsFlag: Bool = false @AppStorage(WFDefaults.selectedCollectionURL, store: UserDefaults.shared) var selectedCollectionURL: URL? @AppStorage(WFDefaults.lastDraftURL, store: UserDefaults.shared) var lastDraftURL: URL? private(set) var initialPostTitle: String? private(set) var initialPostBody: String? #if os(macOS) var postToUpdate: WFAPost? #endif func saveLastDraft(_ post: WFAPost) { self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil } func clearLastDraft() { self.lastDraftURL = nil } func fetchLastDraftFromAppStorage() -> WFAPost? { guard let postURL = lastDraftURL else { return nil } guard let post = fetchManagedObject(from: postURL) as? WFAPost else { return nil } return post } func generateNewLocalPost(withFont appearance: Int) -> WFAPost { let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) managedPost.createdDate = Date() managedPost.title = "" managedPost.body = "" managedPost.status = PostStatus.local.rawValue - managedPost.collectionAlias = WriteFreelyModel.shared.selectedCollection?.alias + managedPost.collectionAlias = WriteFreelyModel.shared.navState.selectedCollection?.alias switch appearance { case 1: managedPost.appearance = "sans" case 2: managedPost.appearance = "wrap" default: managedPost.appearance = "serif" } if #available(iOS 16, macOS 13, *) { if let languageCode = Locale.current.language.languageCode?.identifier { managedPost.language = languageCode managedPost.rtl = Locale.Language(identifier: languageCode).characterDirection == .rightToLeft } } else { if let languageCode = Locale.current.languageCode { managedPost.language = languageCode managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } } return managedPost } func fetchSelectedCollectionFromAppStorage() -> WFACollection? { guard let collectionURL = selectedCollectionURL else { return nil } guard let collection = fetchManagedObject(from: collectionURL) as? WFACollection else { return nil } return collection } /// Sets the initial values for title and body on a published post. /// /// Used to detect if the title and body have changed back to their initial values. If the passed `WFAPost` isn't /// published, any title and post values already stored are reset to `nil`. /// - Parameter post: The `WFAPost` for which we're setting initial title/body values. mutating func setInitialValues(for post: WFAPost) { if post.status != PostStatus.published.rawValue { initialPostTitle = nil initialPostBody = nil return } initialPostTitle = post.title initialPostBody = post.body } private func fetchManagedObject(from objectURL: URL) -> NSManagedObject? { let coordinator = LocalStorageManager.standard.container.persistentStoreCoordinator guard let managedObjectID = coordinator.managedObjectID(forURIRepresentation: objectURL) else { return nil } let object = LocalStorageManager.standard.container.viewContext.object(with: managedObjectID) return object } } diff --git a/Shared/PostList/DeprecatedListView.swift b/Shared/PostList/DeprecatedListView.swift index f2a6e5a..1c4fb10 100644 --- a/Shared/PostList/DeprecatedListView.swift +++ b/Shared/PostList/DeprecatedListView.swift @@ -1,57 +1,57 @@ import SwiftUI @available(iOS 15, macOS 12.0, *) struct DeprecatedListView: View { @EnvironmentObject var model: WriteFreelyModel @Binding var searchString: String var collections: FetchedResults var fetchRequest: FetchRequest var onDelete: (WFAPost) -> Void var body: some View { - List(selection: $model.selectedPost) { + List(selection: $model.navState.selectedPost) { ForEach(fetchRequest.wrappedValue, id: \.self) { post in if !searchString.isEmpty && !post.title.localizedCaseInsensitiveContains(searchString) && !post.body.localizedCaseInsensitiveContains(searchString) { EmptyView() } else { NavigationLink( destination: PostEditorView(post: post), tag: post, - selection: $model.selectedPost, + selection: $model.navState.selectedPost, label: { - if model.showAllPosts { + if model.navState.showAllPosts { if let collection = collections.filter({ $0.alias == post.collectionAlias }).first { PostCellView(post: post, collectionName: collection.title) } else { // swiftlint:disable:next line_length let collectionName = model.account.server == "https://write.as" ? "Anonymous" : "Drafts" PostCellView(post: post, collectionName: collectionName) } } else { PostCellView(post: post) } }) .deleteDisabled(post.status != PostStatus.local.rawValue) } } .onDelete(perform: { indexSet in for index in indexSet { let post = fetchRequest.wrappedValue[index] delete(post) } }) } #if os(iOS) .searchable(text: $searchString, prompt: "Search across posts") #else .searchable(text: $searchString, placement: .toolbar, prompt: "Search across posts") #endif } func delete(_ post: WFAPost) { onDelete(post) } } diff --git a/Shared/PostList/PostCellView.swift b/Shared/PostList/PostCellView.swift index 9f3a622..acbb49f 100644 --- a/Shared/PostList/PostCellView.swift +++ b/Shared/PostList/PostCellView.swift @@ -1,105 +1,105 @@ import SwiftUI struct PostCellView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var collectionName: String? static let createdDateFormat: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale.current formatter.dateStyle = .long formatter.timeStyle = .short return formatter }() var titleText: String { if post.title.isEmpty { return model.posts.getBodyPreview(of: post) } return post.title } var body: some View { HStack { VStack(alignment: .leading) { if let collectionName = collectionName { Text(collectionName) .font(.caption) .foregroundColor(.secondary) .padding(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.secondary, lineWidth: 1)) } Text(titleText) .font(.headline) Text(post.createdDate ?? Date(), formatter: Self.createdDateFormat) .font(.caption) .foregroundColor(.secondary) .padding(.top, -3) } Spacer() PostStatusBadgeView(post: post) } .padding(5) .contextMenu { Button( action: didTapDeleteContextMenuItem, label: { Label("Delete", systemImage: "trash") } ) .disabled(post.status != PostStatus.local.rawValue) } } private func didTapDeleteContextMenuItem() { guard post.status == PostStatus.local.rawValue else { return } - if post === model.selectedPost { - model.selectedPost = nil + if post === model.navState.selectedPost { + model.navState.selectedPost = nil model.editor.clearLastDraft() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { model.posts.remove(post) } } } struct PostCell_AllPostsPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() return PostCellView(post: testPost, collectionName: "My Cool Blog") .environment(\.managedObjectContext, context) } } struct PostCell_NormalPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.collectionAlias = "My Cool Blog" testPost.createdDate = Date() return PostCellView(post: testPost) .environment(\.managedObjectContext, context) } } struct PostCell_NoTitlePreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "" testPost.body = "Here's some cool sample body text." testPost.collectionAlias = "My Cool Blog" testPost.createdDate = Date() return PostCellView(post: testPost) .environment(\.managedObjectContext, context) } } diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift index 24b2453..3af723a 100644 --- a/Shared/PostList/PostListFilteredView.swift +++ b/Shared/PostList/PostListFilteredView.swift @@ -1,143 +1,143 @@ import SwiftUI struct PostListFilteredView: View { @EnvironmentObject var model: WriteFreelyModel @Binding var postCount: Int @FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults var fetchRequest: FetchRequest init(collection: WFACollection?, showAllPosts: Bool, postCount: Binding) { if showAllPosts { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)] ) } else { if let collectionAlias = collection?.alias { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], predicate: NSPredicate(format: "collectionAlias == %@", collectionAlias) ) } else { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], predicate: NSPredicate(format: "collectionAlias == nil") ) } } _postCount = postCount } var body: some View { #if os(iOS) if #available(iOS 15, *) { SearchablePostListFilteredView( postCount: $postCount, collections: collections, fetchRequest: fetchRequest, onDelete: delete(_:) ) .environmentObject(model) .onAppear(perform: { self.postCount = fetchRequest.wrappedValue.count }) .onChange(of: fetchRequest.wrappedValue.count, perform: { value in self.postCount = value }) } else { - List(selection: $model.selectedPost) { + List(selection: $model.navState.selectedPost) { ForEach(fetchRequest.wrappedValue, id: \.self) { post in NavigationLink( destination: PostEditorView(post: post), tag: post, - selection: $model.selectedPost, + selection: $model.navState.selectedPost, label: { - if model.showAllPosts { + if model.navState.showAllPosts { if let collection = collections.filter({ $0.alias == post.collectionAlias }).first { PostCellView(post: post, collectionName: collection.title) } else { // swiftlint:disable:next line_length let collectionName = model.account.server == "https://write.as" ? "Anonymous" : "Drafts" PostCellView(post: post, collectionName: collectionName) } } else { PostCellView(post: post) } }) .deleteDisabled(post.status != PostStatus.local.rawValue) } .onDelete(perform: { indexSet in for index in indexSet { let post = fetchRequest.wrappedValue[index] delete(post) } }) } .onAppear(perform: { self.postCount = fetchRequest.wrappedValue.count }) .onChange(of: fetchRequest.wrappedValue.count, perform: { value in self.postCount = value }) } #else SearchablePostListFilteredView( postCount: $postCount, collections: collections, fetchRequest: fetchRequest, onDelete: delete(_:) ) .environmentObject(model) .alert(isPresented: $model.isPresentingDeleteAlert) { Alert( title: Text("Delete Post?"), message: Text("This action cannot be undone."), primaryButton: .cancel { model.postToDelete = nil }, secondaryButton: .destructive(Text("Delete"), action: { if let postToDelete = model.postToDelete { model.selectedPost = nil DispatchQueue.main.async { model.editor.clearLastDraft() model.posts.remove(postToDelete) } model.postToDelete = nil } }) ) } .onDeleteCommand(perform: { guard let selectedPost = model.selectedPost else { return } if selectedPost.status == PostStatus.local.rawValue { model.postToDelete = selectedPost model.isPresentingDeleteAlert = true } }) #endif } func delete(_ post: WFAPost) { DispatchQueue.main.async { - if post == model.selectedPost { - model.selectedPost = nil + if post == model.navState.selectedPost { + model.navState.selectedPost = nil model.editor.clearLastDraft() } model.posts.remove(post) } } } struct PostListFilteredView_Previews: PreviewProvider { static var previews: some View { return PostListFilteredView( collection: nil, showAllPosts: false, postCount: .constant(999) ) .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index 06b4714..d47cafa 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,197 +1,197 @@ 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) { ZStack { // 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. if #unavailable(iOS 16) { Spacer() } Button(action: { let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { - self.model.showAllPosts = false + self.model.navState.showAllPosts = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.model.selectedPost = managedPost + self.model.navState.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 { if model.hasNetworkConnection { 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) } else { Image(systemName: "wifi.exclamationmark") .padding(.vertical, 4) .padding(.horizontal, 8) .foregroundColor(.secondary) } } } .padding(.top, 8) .padding(.horizontal, 8) Spacer() } .frame(height: frameHeight) .background(Color(UIColor.systemGray5)) .overlay(Divider(), alignment: .top) } .ignoresSafeArea(.all, edges: .bottom) .onAppear { // Set the selected collection and whether or not we want to show all posts - model.selectedCollection = selectedCollection - model.showAllPosts = showAllPosts + model.navState.selectedCollection = selectedCollection + model.navState.showAllPosts = showAllPosts // 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 } } .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 } } #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) } } diff --git a/Shared/PostList/SearchablePostListFilteredView.swift b/Shared/PostList/SearchablePostListFilteredView.swift index 5279fea..8c79d5e 100644 --- a/Shared/PostList/SearchablePostListFilteredView.swift +++ b/Shared/PostList/SearchablePostListFilteredView.swift @@ -1,39 +1,39 @@ import SwiftUI @available(iOS 15, macOS 12.0, *) struct SearchablePostListFilteredView: View { @EnvironmentObject var model: WriteFreelyModel @Binding var postCount: Int @State private var searchString = "" // Only used for NavigationStack in iOS 16/macOS 13 or later // @State private var path: [WFAPost] = [] var collections: FetchedResults var fetchRequest: FetchRequest var onDelete: (WFAPost) -> Void var body: some View { if #available(iOS 16, macOS 13, *) { NavigationStack { - List(fetchRequest.wrappedValue, id: \.self, selection: $model.selectedPost) { post in + List(fetchRequest.wrappedValue, id: \.self, selection: $model.navState.selectedPost) { post in NavigationLink( "\(post.title.isEmpty ? "UNTITLED" : post.title)", destination: PostEditorView(post: post) ) } } } else { DeprecatedListView( searchString: $searchString, collections: collections, fetchRequest: fetchRequest, onDelete: onDelete ) } } func delete(_ post: WFAPost) { onDelete(post) } } diff --git a/Shared/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index b121314..fae66c4 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -1,212 +1,213 @@ import SwiftUI #if os(macOS) import Sparkle #endif @main struct CheckForDebugModifier { static func main() { #if os(macOS) if NSEvent.modifierFlags.contains(.shift) { // Clear the launch-to-last-draft values to load a new draft. UserDefaults.shared.setValue(false, forKey: WFDefaults.showAllPostsFlag) UserDefaults.shared.setValue(nil, forKey: WFDefaults.selectedCollectionURL) UserDefaults.shared.setValue(nil, forKey: WFDefaults.lastDraftURL) } else { // No-op } #endif WriteFreely_MultiPlatformApp.main() } } struct WriteFreely_MultiPlatformApp: App { @StateObject private var model = WriteFreelyModel.shared private let logger = Logging(for: String(describing: WriteFreely_MultiPlatformApp.self)) #if os(macOS) @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject var updaterViewModel = MacUpdatesViewModel() @State private var selectedTab = 0 #endif @State private var didCrash = UserDefaults.shared.bool(forKey: WFDefaults.didHaveFatalError) var body: some Scene { WindowGroup { ContentView() .onAppear(perform: { if model.editor.showAllPostsFlag { DispatchQueue.main.async { - self.model.selectedCollection = nil - self.model.showAllPosts = true + self.model.navState.selectedCollection = nil + self.model.navState.showAllPosts = true showLastDraftOrCreateNewLocalPost() } } else { DispatchQueue.main.async { - self.model.selectedCollection = model.editor.fetchSelectedCollectionFromAppStorage() - self.model.showAllPosts = false + self.model.navState.selectedCollection = + model.editor.fetchSelectedCollectionFromAppStorage() + self.model.navState.showAllPosts = false showLastDraftOrCreateNewLocalPost() } } }) .alert(isPresented: $didCrash) { var helpMsg = "Alert the humans by sharing what happened on the help forum." if let errorMsg = UserDefaults.shared.object(forKey: WFDefaults.fatalErrorDescription) as? String { helpMsg.append("\n\n\(errorMsg)") } return Alert( title: Text("Crash Detected"), message: Text(helpMsg), primaryButton: .default( Text("Let us know"), action: didPressCrashAlertButton ), secondaryButton: .cancel( Text("Dismiss"), action: resetCrashFlags ) ) } .onAppear { if #available(iOS 15, *) { if didCrash { generateCrashLogPost() } } } .withErrorHandling() .environmentObject(model) .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } .commands { #if os(macOS) CommandGroup(after: .appInfo) { CheckForUpdatesView(updaterViewModel: updaterViewModel) } #endif CommandGroup(replacing: .newItem, addition: { Button("New Post") { createNewLocalPost() } .keyboardShortcut("n", modifiers: [.command]) }) CommandGroup(after: .newItem) { Button("Refresh Posts") { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } .disabled(!model.account.isLoggedIn) .keyboardShortcut("r", modifiers: [.command]) } SidebarCommands() #if os(macOS) PostCommands(model: model) HelpCommands(model: model) #endif ToolbarCommands() TextEditingCommands() } #if os(macOS) Settings { TabView(selection: $selectedTab) { MacAccountView() .environmentObject(model) .tabItem { Image(systemName: "person.crop.circle") Text("Account") } .tag(0) MacPreferencesView(preferences: model.preferences) .tabItem { Image(systemName: "gear") Text("Preferences") } .tag(1) MacUpdatesView(updaterViewModel: updaterViewModel) .tabItem { Image(systemName: "arrow.down.circle") Text("Updates") } .tag(2) } .environmentObject(model) .withErrorHandling() .frame(minWidth: 500, maxWidth: 500, minHeight: 200) .padding() // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } #endif } private func showLastDraftOrCreateNewLocalPost() { if model.editor.lastDraftURL != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() + self.model.navState.selectedPost = model.editor.fetchLastDraftFromAppStorage() } } else { createNewLocalPost() } } private func createNewLocalPost() { withAnimation { // Un-set the currently selected post - self.model.selectedPost = nil + self.model.navState.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { // Set it as the selectedPost DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - self.model.selectedPost = managedPost + self.model.navState.selectedPost = managedPost } } } @available(iOS 15, *) private func generateCrashLogPost() { logger.log("Generating local log post...") DispatchQueue.main.asyncAfter(deadline: .now()) { // Unset selected post and collection and navigate to local drafts. - self.model.selectedPost = nil - self.model.selectedCollection = nil - self.model.showAllPosts = false + self.model.navState.selectedPost = nil + self.model.navState.selectedCollection = nil + self.model.navState.showAllPosts = false // Create the new log post. let newLogPost = model.editor.generateNewLocalPost(withFont: 2) newLogPost.title = "Logs For Support" var postBody: [String] = [ "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", "Generated \(Date())", "" ] postBody.append(contentsOf: logger.fetchLogs()) newLogPost.body = postBody.joined(separator: "\n") - self.model.selectedPost = newLogPost + self.model.navState.selectedPost = newLogPost } logger.log("Generated local log post.") } private func resetCrashFlags() { UserDefaults.shared.set(false, forKey: WFDefaults.didHaveFatalError) UserDefaults.shared.removeObject(forKey: WFDefaults.fatalErrorDescription) } private func didPressCrashAlertButton() { resetCrashFlags() #if os(macOS) NSWorkspace().open(model.helpURL) #else UIApplication.shared.open(model.helpURL) #endif } } diff --git a/iOS/Settings/SettingsView.swift b/iOS/Settings/SettingsView.swift index cfd0211..e75b2d2 100644 --- a/iOS/Settings/SettingsView.swift +++ b/iOS/Settings/SettingsView.swift @@ -1,105 +1,105 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var model: WriteFreelyModel @State private var isShowingAlert = false private let logger = Logging(for: String(describing: SettingsView.self)) var body: some View { VStack { SettingsHeaderView() Form { Section(header: Text("Login Details")) { AccountView() .withErrorHandling() } Section(header: Text("Appearance")) { PreferencesView(preferences: model.preferences) } Section(header: Text("Help and Support")) { Link("View the Guide", destination: model.howToURL) Link("Visit the Help Forum", destination: model.helpURL) Link("Write a Review on the App Store", destination: model.reviewURL) if #available(iOS 15.0, *) { VStack(alignment: .leading, spacing: 8) { Button( action: didTapGenerateLogPostButton, label: { Text("Create Log Post") } ) Text("Generates a local post using recent logs. You can share this for troubleshooting.") .font(.footnote) .foregroundColor(.secondary) } } } Section(header: Text("Acknowledgements")) { VStack { VStack(alignment: .leading) { Text("This application makes use of the following open-source projects:") .padding(.bottom) Text("• Lora typeface") .padding(.leading) Text("• Open Sans typeface") .padding(.leading) Text("• Hack typeface") .padding(.leading) } .padding(.bottom) .foregroundColor(.secondary) HStack { Spacer() Link("View the licenses", destination: model.licensesURL) Spacer() } } .padding() } } } .alert(isPresented: $isShowingAlert) { Alert( title: Text("Log Post Created"), message: Text("Check your local drafts for app logs from the past 24 hours.") ) } // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } @available(iOS 15, *) private func didTapGenerateLogPostButton() { logger.log("Generating local log post...") DispatchQueue.main.asyncAfter(deadline: .now()) { // Unset selected post and collection and navigate to local drafts. - self.model.selectedPost = nil - self.model.selectedCollection = nil - self.model.showAllPosts = false + self.model.navState.selectedPost = nil + self.model.navState.selectedCollection = nil + self.model.navState.showAllPosts = false // Create the new log post. let newLogPost = model.editor.generateNewLocalPost(withFont: 2) newLogPost.title = "Logs For Support" var postBody: [String] = [ "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", "Generated \(Date())", "" ] postBody.append(contentsOf: logger.fetchLogs()) newLogPost.body = postBody.joined(separator: "\n") self.isShowingAlert = true } logger.log("Generated local log post.") } } struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() .environmentObject(WriteFreelyModel()) } }