diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 02c32f9..de7bb97 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -1,87 +1,89 @@ import SwiftUI struct AccountLoginView: View { @EnvironmentObject var model: WriteFreelyModel @State private var alertMessage: String = "" @State private var username: String = "" @State private var password: String = "" @State private var server: String = "" var body: some View { VStack { Text("Log in to publish and share your posts.") .font(.caption) .foregroundColor(.secondary) HStack { Image(systemName: "person.circle") .foregroundColor(.gray) #if os(iOS) TextField("Username", text: $username) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else TextField("Username", text: $username) #endif } HStack { Image(systemName: "lock.circle") .foregroundColor(.gray) #if os(iOS) SecureField("Password", text: $password) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else SecureField("Password", text: $password) #endif } HStack { Image(systemName: "link.circle") .foregroundColor(.gray) #if os(iOS) TextField("Server URL", text: $server) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else TextField("Server URL", text: $server) #endif } Spacer() if model.isLoggingIn { ProgressView("Logging in...") .padding() } else { Button(action: { + #if os(iOS) hideKeyboard() + #endif model.login( to: URL(string: server)!, as: username, password: password ) }, label: { Text("Log In") }) .disabled( model.account.isLoggedIn || (username.isEmpty || password.isEmpty || server.isEmpty) ) .padding() } } .alert(isPresented: $model.isPresentingLoginErrorAlert) { Alert( title: Text("Error Logging In"), message: Text(model.loginErrorMessage ?? "An unknown error occurred while trying to login."), dismissButton: .default(Text("OK")) ) } } } struct AccountLoginView_Previews: PreviewProvider { static var previews: some View { AccountLoginView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 8613d6b..9f69a50 100644 --- a/Shared/Account/AccountLogoutView.swift +++ b/Shared/Account/AccountLogoutView.swift @@ -1,59 +1,89 @@ import SwiftUI struct AccountLogoutView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc @State private var isPresentingLogoutConfirmation: Bool = false @State private var editedPostsWarningString: String = "" var body: some View { + #if os(iOS) VStack { Spacer() VStack { Text("Logged in as \(model.account.username)") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Log Out") }) } .actionSheet(isPresented: $isPresentingLogoutConfirmation, content: { ActionSheet( title: Text("Log Out?"), message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), buttons: [ .destructive(Text("Log Out"), action: { model.logout() }), .cancel() ] ) }) + #else + VStack { + Spacer() + VStack { + Text("Logged in as \(model.account.username)") + Text("on \(model.account.server)") + } + Spacer() + Button(action: logoutHandler, label: { + Text("Log Out") + }) + } + .sheet(isPresented: $isPresentingLogoutConfirmation) { + VStack { + Text("Log Out?") + .font(.title) + Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?") + HStack { + Button(action: model.logout, label: { + Text("Log Out") + }) + Button(action: { + self.isPresentingLogoutConfirmation = false + }, label: { + Text("Cancel") + }).keyboardShortcut(.cancelAction) + } + } + } + #endif } func logoutHandler() { let request = WFAPost.createFetchRequest() request.predicate = NSPredicate(format: "status == %i", 1) do { let editedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request) if editedPosts.count == 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited post. " } if editedPosts.count > 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " } } catch { print("Error: failed to fetch cached posts") } self.isPresentingLogoutConfirmation = true } } struct AccountLogoutView_Previews: PreviewProvider { static var previews: some View { AccountLogoutView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index ca3764b..c079cf5 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,531 +1,537 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() @Published var isLoggingIn: Bool = false @Published var isProcessingRequest: Bool = false @Published var hasNetworkConnection: Bool = true - @Published var selectedPost: WFAPost? { - didSet { - if let post = selectedPost { - if post.status != PostStatus.published.rawValue { - editor.setLastDraft(post) - } else { - editor.clearLastDraft() - } - } else { - editor.clearLastDraft() - } - } - } + @Published var selectedPost: WFAPost? @Published var isPresentingDeleteAlert: Bool = false @Published var isPresentingLoginErrorAlert: Bool = false @Published var isPresentingNetworkErrorAlert: Bool = false @Published var postToDelete: WFAPost? #if os(iOS) @Published var isPresentingSettingsView: Bool = false #endif var loginErrorMessage: String? // swiftlint:disable line_length let helpURL = URL(string: "https://discuss.write.as/c/help/5")! let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! // swiftlint:enable line_length private var client: WFClient? private let defaults = UserDefaults.standard private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") init() { DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) self.preferences.font = self.defaults.integer(forKey: self.preferences.defaultFontIntegerKey) self.account.restoreState() if self.account.isLoggedIn { guard let serverURL = URL(string: self.account.server) else { print("Server URL not found") return } guard let token = self.fetchTokenFromKeychain( username: self.account.username, server: self.account.server ) else { print("Could not fetch token from Keychain") return } self.account.login(WFUser(token: token, username: self.account.username)) self.client = WFClient(for: serverURL) self.client?.user = self.account.user self.fetchUserCollections() self.fetchUserPosts() } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } // MARK: - WriteFreelyModel API extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { if !hasNetworkConnection { isPresentingNetworkErrorAlert = true 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 { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { do { try purgeTokenFromKeychain(username: account.username, server: account.server) account.logout() } catch { fatalError("Failed to log out persisted state") } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getPosts(completion: fetchUserPostsHandler) } func publish(post: WFAPost) { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } if post.language == nil { 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.createdDate ) if let existingPostId = post.postId { // This is an existing post. wfPost.postId = post.postId wfPost.slug = post.slug wfPost.updatedDate = post.updatedDate wfPost.collectionAlias = post.collectionAlias 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 { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { return } guard let postId = post.postId else { return } // We're starting the network request. DispatchQueue.main.async { self.selectedPost = post self.isProcessingRequest = true } loggedInClient.getPost(byId: postId, completion: updateFromServerHandler) } func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client, let postId = post.postId else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } selectedPost = post post.collectionAlias = newCollection?.alias loggedInClient.movePost(postId: postId, to: newCollection?.alias, completion: movePostHandler) } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() fetchUserPosts() saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch WFError.notFound { DispatchQueue.main.async { self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription self.isPresentingLoginErrorAlert = true } } catch WFError.unauthorized { DispatchQueue.main.async { self.loginErrorMessage = AccountError.invalidPassword.localizedDescription self.isPresentingLoginErrorAlert = true } } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { DispatchQueue.main.async { self.loginErrorMessage = AccountError.serverNotFound.localizedDescription self.isPresentingLoginErrorAlert = true } } else { DispatchQueue.main.async { self.loginErrorMessage = error.localizedDescription self.isPresentingLoginErrorAlert = true } } } } 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().purgeUserCollections() self.posts.purgePublishedPosts() } } catch { print("Something went wrong purging the token from the Keychain.") } } 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().purgeUserCollections() self.posts.purgePublishedPosts() } } catch { print("Something went wrong purging the token from the Keychain.") } } 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.persistentContainer.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().saveContext() } } catch { print(error) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } + let request = WFAPost.createFetchRequest() do { - var postsToDelete = posts.userPosts.filter { $0.status != PostStatus.local.rawValue } - let fetchedPosts = try result.get() - for fetchedPost in fetchedPosts { - if let managedPost = posts.userPosts.first(where: { $0.postId == fetchedPost.postId }) { - managedPost.wasDeletedFromServer = false - if let fetchedPostUpdatedDate = fetchedPost.updatedDate, - let localPostUpdatedDate = managedPost.updatedDate { - managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate + let locallyCachedPosts = try LocalStorageManager.persistentContainer.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") } + postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) + } } else { - print("Error: could not determine which copy of post is newer") + DispatchQueue.main.async { + let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + managedPost.postId = fetchedPost.postId + managedPost.slug = fetchedPost.slug + managedPost.appearance = fetchedPost.appearance + managedPost.language = fetchedPost.language + managedPost.rtl = fetchedPost.rtl ?? false + managedPost.createdDate = fetchedPost.createdDate + managedPost.updatedDate = fetchedPost.updatedDate + managedPost.title = fetchedPost.title ?? "" + managedPost.body = fetchedPost.body + managedPost.collectionAlias = fetchedPost.collectionAlias + managedPost.status = PostStatus.published.rawValue + managedPost.wasDeletedFromServer = false + } } - postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) - } else { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) - managedPost.postId = fetchedPost.postId - managedPost.slug = fetchedPost.slug - managedPost.appearance = fetchedPost.appearance - managedPost.language = fetchedPost.language - managedPost.rtl = fetchedPost.rtl ?? false - managedPost.createdDate = fetchedPost.createdDate - managedPost.updatedDate = fetchedPost.updatedDate - managedPost.title = fetchedPost.title ?? "" - managedPost.body = fetchedPost.body - managedPost.collectionAlias = fetchedPost.collectionAlias - managedPost.status = PostStatus.published.rawValue - managedPost.wasDeletedFromServer = false } - } - for post in postsToDelete { - post.wasDeletedFromServer = true - } - DispatchQueue.main.async { - LocalStorageManager().saveContext() - self.posts.loadCachedPosts() + DispatchQueue.main.async { + for post in postsToDelete { post.wasDeletedFromServer = true } + LocalStorageManager().saveContext() + } + } catch { + print(error) } } catch { - print(error) + print("Error: Failed to fetch cached posts") } } 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() - let foundPostIndex = posts.userPosts.firstIndex(where: { - $0.title == fetchedPost.title && $0.body == fetchedPost.body - }) - guard let index = foundPostIndex else { return } - let cachedPost = self.posts.userPosts[index] - 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 - DispatchQueue.main.async { - LocalStorageManager().saveContext() + 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.persistentContainer.viewContext.fetch(request) + guard let cachedPost = cachedPostsResults.first else { return } + 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 + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + } catch { + print("Error: Failed to fetch cached posts") } } catch { print(error) } } 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 } 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 cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager().saveContext() - self.posts.loadCachedPosts() } } catch { print(error) } } 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.persistentContainer.viewContext.rollback() } print(error) } } } private extension WriteFreelyModel { // MARK: - Keychain Helpers func saveTokenToKeychain(_ token: String, username: String?, server: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecValueData as String: token.data(using: .utf8)!, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecDuplicateItem || status == errSecSuccess else { fatalError("Error storing in Keychain with OSStatus: \(status)") } } func purgeTokenFromKeychain(username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { fatalError("Error deleting from Keychain with OSStatus: \(status)") } } func fetchTokenFromKeychain(username: String?, server: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true ] var secItem: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &secItem) guard status != errSecItemNotFound else { return nil } guard status == errSecSuccess else { fatalError("Error fetching from Keychain with OSStatus: \(status)") } guard let existingSecItem = secItem as? [String: Any], let tokenData = existingSecItem[kSecValueData as String] as? Data, let token = String(data: tokenData, encoding: .utf8) else { return nil } return token } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 426b112..6831b7c 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,91 +1,67 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { SidebarView() PostListView(selectedCollection: nil, showAllPosts: model.account.isLoggedIn) Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } - .onAppear(perform: { - if let lastDraft = self.model.editor.fetchLastDraft() { - model.selectedPost = lastDraft - } else { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) - managedPost.createdDate = Date() - managedPost.title = "" - managedPost.body = "" - managedPost.status = PostStatus.local.rawValue - switch self.model.preferences.font { - case 1: - managedPost.appearance = "sans" - case 2: - managedPost.appearance = "wrap" - default: - managedPost.appearance = "serif" - } - if let languageCode = Locale.current.languageCode { - managedPost.language = languageCode - managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft - } - model.selectedPost = managedPost - } - }) .environmentObject(model) .alert(isPresented: $model.isPresentingDeleteAlert) { Alert( title: Text("Delete Post?"), message: Text("This action cannot be undone."), primaryButton: .destructive(Text("Delete"), action: { if let postToDelete = model.postToDelete { model.selectedPost = nil - withAnimation { + DispatchQueue.main.async { model.posts.remove(postToDelete) } model.postToDelete = nil } }), secondaryButton: .cancel() { model.postToDelete = nil } ) } .alert(isPresented: $model.isPresentingNetworkErrorAlert, content: { Alert( title: Text("Connection Error"), message: Text("There is no internet connection at the moment. Please reconnect or try again later"), dismissButton: .default(Text("OK"), action: { model.isPresentingNetworkErrorAlert = false }) ) }) #if os(iOS) EmptyView() .sheet( isPresented: $model.isPresentingSettingsView, onDismiss: { model.isPresentingSettingsView = false }, content: { SettingsView() .environmentObject(model) } ) #endif } } struct ContentView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 9dbcf68..23a4aa5 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,52 +1,51 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { List { if model.account.isLoggedIn { NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: true)) { Text("All Posts") } NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { Text(model.account.server == "https://write.as" ? "Anonymous" : "Drafts") } Section(header: Text("Your Blogs")) { ForEach(collections, id: \.alias) { collection in NavigationLink( destination: PostListView(selectedCollection: collection, showAllPosts: false) ) { 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()) } } struct CollectionListView_LoggedOutPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() return CollectionListView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index 84e774c..8d83713 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,38 +1,32 @@ -import Foundation +import SwiftUI import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" case mono = "Hack-Regular" case serif = "Lora-Regular" } struct PostEditorModel { - let lastDraftObjectURLKey = "lastDraftObjectURLKey" - private(set) var lastDraft: WFAPost? + @AppStorage("lastDraftURL") private var lastDraftURL: URL? - mutating func setLastDraft(_ post: WFAPost) { - lastDraft = post - UserDefaults.standard.set(post.objectID.uriRepresentation(), forKey: lastDraftObjectURLKey) + func saveLastDraft(_ post: WFAPost) { + self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil } - mutating func fetchLastDraft() -> WFAPost? { - let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator - - // See if we have a lastDraftObjectURI - guard let lastDraftObjectURI = UserDefaults.standard.url(forKey: lastDraftObjectURLKey) else { return nil } + func clearLastDraft() { + self.lastDraftURL = nil + } - // See if we can get an ObjectID from the URI representation - guard let lastDraftObjectID = coordinator.managedObjectID(forURIRepresentation: lastDraftObjectURI) else { - return nil - } + func fetchLastDraftFromUserDefaults() -> WFAPost? { + guard let postURL = lastDraftURL else { return nil } - lastDraft = LocalStorageManager.persistentContainer.viewContext.object(with: lastDraftObjectID) as? WFAPost - return lastDraft - } + let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator + guard let postManagedObjectID = coordinator.managedObjectID(forURIRepresentation: postURL) else { return nil } + guard let post = LocalStorageManager.persistentContainer.viewContext.object( + with: postManagedObjectID + ) as? WFAPost else { return nil } - mutating func clearLastDraft() { - lastDraft = nil - UserDefaults.standard.removeObject(forKey: lastDraftObjectURLKey) + return post } } diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift index bb42ba3..6b37511 100644 --- a/Shared/PostList/PostListFilteredView.swift +++ b/Shared/PostList/PostListFilteredView.swift @@ -1,101 +1,114 @@ import SwiftUI struct PostListFilteredView: View { @EnvironmentObject var model: WriteFreelyModel - + @Binding var postCount: Int @FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults var fetchRequest: FetchRequest var showAllPosts: Bool - init(filter: String?, showAllPosts: Bool) { + init(filter: String?, showAllPosts: Bool, postCount: Binding) { self.showAllPosts = showAllPosts if showAllPosts { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)] ) } else { if let filter = filter { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], predicate: NSPredicate(format: "collectionAlias == %@", filter) ) } 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) List { ForEach(fetchRequest.wrappedValue, id: \.self) { post in NavigationLink( destination: PostEditorView(post: post), tag: post, selection: $model.selectedPost ) { if showAllPosts { if let collection = collections.filter { $0.alias == post.collectionAlias }.first { PostCellView(post: post, collectionName: collection.title) } else { 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 List { ForEach(fetchRequest.wrappedValue, id: \.self) { post in NavigationLink( destination: PostEditorView(post: post), tag: post, selection: $model.selectedPost ) { 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 + }) .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) { model.posts.remove(post) } } struct PostListFilteredView_Previews: PreviewProvider { static var previews: some View { - return PostListFilteredView(filter: nil, showAllPosts: false) + return PostListFilteredView(filter: nil, showAllPosts: false, postCount: .constant(999)) } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 774a451..e6464e4 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -1,43 +1,21 @@ import SwiftUI import CoreData class PostListModel: ObservableObject { - @Published var userPosts = [WFAPost]() - - init() { - loadCachedPosts() - } - - func loadCachedPosts() { - let request = WFAPost.createFetchRequest() - let sort = NSSortDescriptor(key: "createdDate", ascending: false) - request.sortDescriptors = [sort] - - userPosts = [] - do { - let cachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request) - userPosts.append(contentsOf: cachedPosts) - } catch { - print("Error: Failed to fetch cached posts.") - } - } - func remove(_ post: WFAPost) { LocalStorageManager.persistentContainer.viewContext.delete(post) LocalStorageManager().saveContext() } func purgePublishedPosts() { - userPosts = [] let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") fetchRequest.predicate = NSPredicate(format: "status != %i", 0) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try LocalStorageManager.persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest) - loadCachedPosts() } catch { print("Error: Failed to purge cached posts.") } } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index bdaad24..dddca79 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,143 +1,125 @@ import SwiftUI +import Combine struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc + @Environment(\.managedObjectContext) var managedObjectContext @State var selectedCollection: WFACollection? @State var showAllPosts: Bool = false + @State private var postCount: Int = 0 var body: some View { #if os(iOS) GeometryReader { geometry in - PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts) + PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: { createNewLocalDraft() }, label: { Image(systemName: "square.and.pencil") }) } ToolbarItem(placement: .bottomBar) { HStack { Button(action: { model.isPresentingSettingsView = true }, label: { Image(systemName: "gear") }) Spacer() - Text(pluralizedPostCount(for: showPosts(for: selectedCollection))) + Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") .foregroundColor(.secondary) Spacer() if model.isProcessingRequest { ProgressView() } else { Button(action: { reloadFromServer() }, label: { Image(systemName: "arrow.clockwise") }) .disabled(!model.account.isLoggedIn) } } .padding() .frame(width: geometry.size.width) } } } #else //if os(macOS) - PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts) - .navigationTitle( - showAllPosts ? "All Posts" : selectedCollection?.title ?? ( - model.account.server == "https://write.as" ? "Anonymous" : "Drafts" + PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount) + .navigationTitle( + showAllPosts ? "All Posts" : selectedCollection?.title ?? ( + model.account.server == "https://write.as" ? "Anonymous" : "Drafts" + ) ) - ) - .navigationSubtitle(pluralizedPostCount(for: showPosts(for: selectedCollection))) - .toolbar { - Button(action: { - createNewLocalDraft() - }, label: { - Image(systemName: "square.and.pencil") - }) - Button(action: { - reloadFromServer() - }, label: { - Image(systemName: "arrow.clockwise") - }) - .disabled(!model.account.isLoggedIn) - } - #endif - } - - private func pluralizedPostCount(for posts: [WFAPost]) -> String { - if posts.count == 1 { - return "1 post" - } else { - return "\(posts.count) posts" - } - } - - private func showPosts(for collection: WFACollection?) -> [WFAPost] { - if showAllPosts { - return model.posts.userPosts - } else { - if let selectedCollection = collection { - return model.posts.userPosts.filter { $0.collectionAlias == selectedCollection.alias } - } else { - return model.posts.userPosts.filter { $0.collectionAlias == nil } + .navigationSubtitle(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") + .toolbar { + Button(action: { + createNewLocalDraft() + }, label: { + Image(systemName: "square.and.pencil") + }) + Button(action: { + reloadFromServer() + }, label: { + Image(systemName: "arrow.clockwise") + }) + .disabled(!model.account.isLoggedIn) } - } + #endif } private func reloadFromServer() { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } private func createNewLocalDraft() { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + let managedPost = WFAPost(context: self.managedObjectContext) managedPost.createdDate = Date() managedPost.title = "" managedPost.body = "" managedPost.status = PostStatus.local.rawValue managedPost.collectionAlias = nil switch model.preferences.font { case 1: managedPost.appearance = "sans" case 2: managedPost.appearance = "wrap" default: managedPost.appearance = "serif" } if let languageCode = Locale.current.languageCode { managedPost.language = languageCode managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } DispatchQueue.main.async { self.selectedCollection = nil self.showAllPosts = false withAnimation { self.model.selectedPost = managedPost } } } } struct PostListView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() return PostListView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 36d05f6..a5699f4 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,245 +1,238 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: 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 { VStack { if post.hasNewerRemoteCopy { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { model.updateFromServer(post: post) } ) } else if post.wasDeletedFromServer { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { self.presentationMode.wrappedValue.dismiss() DispatchQueue.main.async { model.posts.remove(post) } } ) } PostTextEditingView( post: _post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) } .navigationBarTitleDisplayMode(.inline) .padding() .toolbar { ToolbarItem(placement: .principal) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { if model.isProcessingRequest { ProgressView() } else { Menu(content: { if post.status == PostStatus.local.rawValue { Menu(content: { Label("Publish to…", systemImage: "paperplane") Button(action: { if model.account.isLoggedIn { post.collectionAlias = nil publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { post.collectionAlias = collection.alias publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) } else { Button(action: { if model.account.isLoggedIn { publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Label("Publish", systemImage: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } Button(action: { sharePost() }, label: { Label("Share", systemImage: "square.and.arrow.up") }) .disabled(post.postId == nil) // Button(action: { // print("Tapped 'Delete...' button") // }, label: { // Label("Delete…", systemImage: "trash") // }) if model.account.isLoggedIn && post.status != PostStatus.local.rawValue { Section(header: Text("Move To Collection")) { Label("Move to:", systemImage: "arrowshape.zigzag.right") Picker(selection: $selectedCollection, label: Text("Move to…")) { Text( " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")" ).tag(nil as WFACollection?) ForEach(collections) { collection in Text(" \(collection.title)").tag(collection as WFACollection?) } } } } }, label: { Image(systemName: "ellipsis.circle") }) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) - .onChange(of: post.status, perform: { _ in - if post.status != PostStatus.published.rawValue { - DispatchQueue.main.async { - model.editor.setLastDraft(post) - } - } else { - DispatchQueue.main.async { - model.editor.clearLastDraft() - } - } - }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } + if post.status != PostStatus.published.rawValue { + self.model.editor.saveLastDraft(post) + } else { + self.model.editor.clearLastDraft() + } }) .onDisappear(perform: { if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) - model.posts.loadCachedPosts() } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager().saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() - model.posts.loadCachedPosts() model.publish(post: post) } #if os(iOS) self.hideKeyboard() #endif } private func sharePost() { // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early. guard let postId = post.postId else { return } var urlString: String if let postSlug = post.slug, let postCollectionAlias = post.collectionAlias { // This post is in a collection, so share the URL as server/collectionAlias/postSlug. urlString = "\(model.account.server)/\((postCollectionAlias))/\((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 } let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil) UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil) if UIDevice.current.userInterfaceIdiom == .pad { activityView.popoverPresentationController?.permittedArrowDirections = .up activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first activityView.popoverPresentationController?.sourceRect = CGRect( x: UIScreen.main.bounds.width, y: -125, width: 200, height: 200 ) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" testPost.hasNewerRemoteCopy = true let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/macOS/PostEditor/PostEditorView.swift b/macOS/PostEditor/PostEditorView.swift index ba7cb53..6d2b238 100644 --- a/macOS/PostEditor/PostEditorView.swift +++ b/macOS/PostEditor/PostEditorView.swift @@ -1,112 +1,99 @@ import SwiftUI struct PostEditorView: View { private let bodyLineSpacing: CGFloat = 17 * 0.5 @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost @State private var isHovering: Bool = false @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: Bool = false var body: some View { PostTextEditingView( post: post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) .padding() .toolbar { ToolbarItem(placement: .status) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { Button(action: { if model.account.isLoggedIn { publishPost() } else { let mainMenu = NSApplication.shared.mainMenu let appMenuItem = mainMenu?.item(withTitle: "WriteFreely") let prefsItem = appMenuItem?.submenu?.item(withTitle: "Preferences…") NSApplication.shared.sendAction(prefsItem!.action!, to: prefsItem?.target, from: nil) } }, label: { Image(systemName: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if post.status == PostStatus.edited.rawValue && !post.hasNewerRemoteCopy { post.status = PostStatus.published.rawValue } }) - .onChange(of: post.status, perform: { _ in - if post.status != PostStatus.published.rawValue { - DispatchQueue.main.async { - model.editor.setLastDraft(post) - } - } else { - DispatchQueue.main.async { - model.editor.clearLastDraft() - } - } - }) .onDisappear(perform: { if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) - model.posts.loadCachedPosts() } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager().saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() - model.posts.loadCachedPosts() model.publish(post: post) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } }