diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index beb186b..ec1521d 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.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.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 + self.navState.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 = 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 e67e145..6c26e4f 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,115 +1,118 @@ 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 + self.model.navState.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 + self.model.navState.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) + PostListView( + selectedCollection: model.navState.selectedCollection, + showAllPosts: model.navState.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.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/PostEditor/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift index 8d98614..27b12ad 100644 --- a/Shared/PostEditor/PostEditorStatusToolbarView.swift +++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift @@ -1,106 +1,106 @@ import SwiftUI struct PostEditorStatusToolbarView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var body: some View { if post.hasNewerRemoteCopy { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { Text("⚠️ Newer copy on server. Replace local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.editor.postToUpdate = post model.updateFromServer(post: post) DispatchQueue.main.async { - model.selectedPost = nil + model.navState.selectedPost = nil } }, label: { Image(systemName: "square.and.arrow.down") }) .accessibilityLabel(Text("Update post")) .accessibilityHint(Text("Replace this post with the server version")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { Text("⚠️ Post deleted from server. Delete local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { - model.selectedPost = nil + model.navState.selectedPost = nil DispatchQueue.main.async { model.posts.remove(post) } }, label: { Image(systemName: "trash") }) .accessibilityLabel(Text("Delete")) .accessibilityHint(Text("Delete this post from your Mac")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else { PostStatusBadgeView(post: post) } } } struct PESTView_StandardPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let testPost = WFAPost(context: context) testPost.status = PostStatus.published.rawValue return PostEditorStatusToolbarView(post: testPost) .environmentObject(model) } } struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let updatedPost = WFAPost(context: context) updatedPost.status = PostStatus.published.rawValue updatedPost.hasNewerRemoteCopy = true return PostEditorStatusToolbarView(post: updatedPost) .environmentObject(model) } } struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let deletedPost = WFAPost(context: context) deletedPost.status = PostStatus.published.rawValue deletedPost.wasDeletedFromServer = true return PostEditorStatusToolbarView(post: deletedPost) .environmentObject(model) } } diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift index 3af723a..0bbdab4 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.navState.selectedPost) { ForEach(fetchRequest.wrappedValue, id: \.self) { post in NavigationLink( destination: PostEditorView(post: post), tag: post, selection: $model.navState.selectedPost, label: { 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 + model.navState.selectedPost = nil DispatchQueue.main.async { model.editor.clearLastDraft() model.posts.remove(postToDelete) } model.postToDelete = nil } }) ) } .onDeleteCommand(perform: { - guard let selectedPost = model.selectedPost else { return } + guard let selectedPost = model.navState.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.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 d47cafa..e1c4ac6 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.navState.showAllPosts = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 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.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!) + if model.navState.selectedPost != nil { + ActivePostToolbarView(activePost: model.navState.selectedPost!) } } } .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .onAppear { - model.selectedCollection = selectedCollection - model.showAllPosts = showAllPosts + model.navState.selectedCollection = selectedCollection + model.navState.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/macOS/Navigation/ActivePostToolbarView.swift b/macOS/Navigation/ActivePostToolbarView.swift index 911689d..bc454fe 100644 --- a/macOS/Navigation/ActivePostToolbarView.swift +++ b/macOS/Navigation/ActivePostToolbarView.swift @@ -1,152 +1,152 @@ import SwiftUI struct ActivePostToolbarView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var activePost: WFAPost @State private var isPresentingSharingServicePicker: Bool = false @State private var selectedCollection: WFACollection? @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { HStack { if model.account.isLoggedIn && activePost.status != PostStatus.local.rawValue && !(activePost.wasDeletedFromServer || activePost.hasNewerRemoteCopy) { Section(header: Text("Move To:")) { Picker(selection: $selectedCollection, label: Text("Move To…"), content: { Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") .tag(nil as WFACollection?) Divider() ForEach(collections) { collection in Text("\(collection.title)").tag(collection as WFACollection?) } }) } } PostEditorStatusToolbarView(post: activePost) .frame(minWidth: 50, alignment: .center) .layoutPriority(1) .padding(.horizontal) if activePost.status == PostStatus.edited.rawValue { Button(action: { model.editor.postToUpdate = activePost model.updateFromServer(post: activePost) - model.selectedPost = nil + model.navState.selectedPost = nil }, label: { Image(systemName: "clock.arrow.circlepath") .accessibilityLabel(Text("Revert post")) .accessibilityHint(Text("Replace the edited post with the published version from the server")) }) } if activePost.status == PostStatus.local.rawValue { Menu(content: { Label("Publish To:", systemImage: "paperplane") Divider() Button(action: { if model.account.isLoggedIn { withAnimation { activePost.collectionAlias = nil publishPost(activePost) } } else { openSettingsWindow() } }, label: { Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { withAnimation { activePost.collectionAlias = collection.alias publishPost(activePost) } } else { openSettingsWindow() } }, label: { Text("\(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) - .disabled(model.selectedPost?.body.isEmpty ?? true) + .disabled(model.navState.selectedPost?.body.isEmpty ?? true) .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length } else { HStack(spacing: 4) { Button( action: { self.isPresentingSharingServicePicker = true }, label: { Image(systemName: "square.and.arrow.up") } ) .disabled(activePost.status == PostStatus.local.rawValue) .help("Copy the post's URL to your Mac's pasteboard.") .background( PostEditorSharingPicker( isPresented: $isPresentingSharingServicePicker, sharingItems: createPostUrl() ) ) Button(action: { publishPost(activePost) }, label: { Image(systemName: "paperplane") }) .disabled(activePost.body.isEmpty || activePost.status == PostStatus.published.rawValue) .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length } } } .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == activePost.collectionAlias } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if activePost.collectionAlias == newCollection?.alias { return } else { withAnimation { activePost.collectionAlias = newCollection?.alias model.move(post: activePost, from: selectedCollection, to: newCollection) } } }) } private func createPostUrl() -> [NSURL] { - guard let postId = model.selectedPost?.postId else { return [] } + guard let postId = model.navState.selectedPost?.postId else { return [] } var urlString: String - if let postSlug = model.selectedPost?.slug, - let postCollectionAlias = model.selectedPost?.collectionAlias { + if let postSlug = model.navState.selectedPost?.slug, + let postCollectionAlias = model.navState.selectedPost?.collectionAlias { // This post is in a collection, so share the URL as baseURL/postSlug let urls = collections.filter { $0.alias == postCollectionAlias } let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/" urlString = "\(baseURL)\(postSlug)" } else { // This is a draft post, so share the URL as server/postID urlString = "\(model.account.server)/\(postId)" } guard let data = URL(string: urlString) else { return [] } return [data as NSURL] } private func publishPost(_ post: WFAPost) { - if post != model.selectedPost { + if post != model.navState.selectedPost { return } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() model.publish(post: post) } model.editor.setInitialValues(for: post) } private func openSettingsWindow() { guard let menuItem = NSApplication.shared.mainMenu?.item(at: 0)?.submenu?.item(at: 2) else { return } NSApplication.shared.sendAction(menuItem.action!, to: menuItem.target, from: nil) } } diff --git a/macOS/Navigation/HelpCommands.swift b/macOS/Navigation/HelpCommands.swift index c465aeb..d2cff07 100644 --- a/macOS/Navigation/HelpCommands.swift +++ b/macOS/Navigation/HelpCommands.swift @@ -1,47 +1,47 @@ import SwiftUI struct HelpCommands: Commands { @ObservedObject var model: WriteFreelyModel private let logger = Logging(for: String(describing: PostCommands.self)) var body: some Commands { CommandGroup(replacing: .help) { Button("Visit Support Forum") { NSWorkspace().open(model.helpURL) } Button(action: createLogsPost, label: { Text("Generate Log for Support") }) } } private func createLogsPost() { logger.log("Generating local log post...") // Show the spinner going in the post list model.isProcessingRequest = true 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") // Hide the spinner in the post list and set the log post as active self.model.isProcessingRequest = false - self.model.selectedPost = newLogPost + self.model.navState.selectedPost = newLogPost logger.log("Generated local log post.") } } } diff --git a/macOS/Navigation/PostCommands.swift b/macOS/Navigation/PostCommands.swift index 1a2b7b6..0c8bc32 100644 --- a/macOS/Navigation/PostCommands.swift +++ b/macOS/Navigation/PostCommands.swift @@ -1,35 +1,35 @@ import SwiftUI struct PostCommands: Commands { @ObservedObject var model: WriteFreelyModel var body: some Commands { CommandMenu("Post") { Button("Find In Posts") { if let toolbar = NSApp.keyWindow?.toolbar, let search = toolbar.items.first(where: { $0.itemIdentifier.rawValue == "com.apple.SwiftUI.search" }) as? NSSearchToolbarItem { search.beginSearchInteraction() } } .keyboardShortcut("f", modifiers: [.command, .shift]) Group { Button(action: sendPostUrlToPasteboard, label: { Text("Copy Link To Published Post") }) - .disabled(model.selectedPost?.status == PostStatus.local.rawValue) + .disabled(model.navState.selectedPost?.status == PostStatus.local.rawValue) } - .disabled(model.selectedPost == nil || !model.account.isLoggedIn) + .disabled(model.navState.selectedPost == nil || !model.account.isLoggedIn) } } private func sendPostUrlToPasteboard() { - guard let activePost = model.selectedPost else { return } + guard let activePost = model.navState.selectedPost else { return } guard let postId = activePost.postId else { return } guard let urlString = activePost.slug != nil ? "\(model.account.server)/\((activePost.collectionAlias)!)/\((activePost.slug)!)" : "\(model.account.server)/\((postId))" else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(urlString, forType: .string) } }