diff --git a/Shared/Models/Post.swift b/Shared/Models/Post.swift index a13656d..bcb8e56 100644 --- a/Shared/Models/Post.swift +++ b/Shared/Models/Post.swift @@ -1,101 +1,102 @@ import Foundation import WriteFreely enum PostStatus { case draft case edited case published } class Post: Identifiable, ObservableObject { @Published var title: String @Published var body: String @Published var createdDate: Date @Published var status: PostStatus @Published var collection: PostCollection @Published var wfPost: WFPost? let id = UUID() init( title: String = "Title", body: String = "Write your post here...", createdDate: Date = Date(), status: PostStatus = .draft, collection: PostCollection = draftsCollection ) { self.title = title self.body = body self.createdDate = createdDate self.status = status self.collection = collection } convenience init(wfPost: WFPost, in collection: PostCollection = draftsCollection) { self.init( title: wfPost.title ?? "", body: wfPost.body, createdDate: wfPost.createdDate ?? Date(), status: .published, collection: collection ) + self.wfPost = wfPost } } #if DEBUG let testPost = Post( title: "Test Post Title", body: """ Here's some cool sample body text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ultrices \ posuere dignissim. Vestibulum a libero tempor, lacinia nulla vitae, congue purus. Nunc ac nulla quam. Duis \ tincidunt eros augue, et volutpat tortor pulvinar ut. Nullam sit amet maximus urna. Phasellus non dignissim lacus.\ Nulla ac posuere ex. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec \ non molestie mauris. Suspendisse potenti. Vivamus at erat turpis. Pellentesque porttitor gravida tincidunt. Sed vitae eros non metus aliquam hendrerit. Aliquam sed risus suscipit \ turpis dictum dictum. Duis lacus lectus, dictum vel felis in, rhoncus fringilla felis. Nunc id dolor nisl. Aliquam \ euismod purus elit. Nullam egestas neque leo, sed aliquet ligula ultrices nec. """, createdDate: Date() ) let testPostData = [ Post( title: "My First Post", body: "Look at me, creating a first post! That's cool.", createdDate: Date(timeIntervalSince1970: 1595429452), status: .published, collection: userCollection1 ), Post( title: "Post 2: The Quickening", body: "See, here's the rule about Highlander jokes: _there can be only one_.", createdDate: Date(timeIntervalSince1970: 1595514125), status: .edited, collection: userCollection1 ), Post( title: "The Post Revolutions", body: "I can never keep the Matrix movie order straight. Why not just call them part 2 and part 3?", createdDate: Date(timeIntervalSince1970: 1595600006) ), Post( title: "Episode IV: A New Post", body: "How many movies does this person watch? How many movie-title jokes will they make?", createdDate: Date(timeIntervalSince1970: 1596219877), status: .published, collection: userCollection2 ), Post( title: "Fast (Post) Five", body: "Look, it was either a Fast and the Furious reference, or a Resident Evil reference." ), Post( title: "Post: The Final Chapter", body: "And there you have it, a Resident Evil movie reference.", createdDate: Date(timeIntervalSince1970: 1596043684), status: .edited, collection: userCollection3 ) ] #endif diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 136cd9f..8d732de 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,240 +1,285 @@ import Foundation import WriteFreely import Security // MARK: - WriteFreelyModel class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var store = PostStore() @Published var collections = CollectionListModel(with: []) - @Published var post: Post? @Published var isLoggingIn: Bool = false private var client: WFClient? private let defaults = UserDefaults.standard init() { // Set the color scheme based on what's been saved in UserDefaults. DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) } #if DEBUG // for post in testPostData { store.add(post) } #endif DispatchQueue.main.async { self.account.restoreState() } } } // MARK: - WriteFreelyModel API extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { isLoggingIn = true account.server = server.absoluteString client = WFClient(for: server) client?.login(username: username, password: password, completion: loginHandler) } func logout() { 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() { guard let loggedInClient = client else { return } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { guard let loggedInClient = client else { return } loggedInClient.getPosts(completion: fetchUserPostsHandler) } + + func publish(post: Post) { + guard let loggedInClient = client else { return } + + if post.wfPost == nil { + // This is a new local draft. + post.wfPost = WFPost( + body: post.body, + title: post.title, + createdDate: post.createdDate + ) + loggedInClient.createPost( + post: post.wfPost!, in: post.collection.wfCollection?.alias, completion: publishHandler + ) + } else { + // This is an existing post. + // 1. Update Post.wfPost properties from (redundant) Post properties + // FIXME: https://github.com/writeas/writefreely-swiftui-multiplatform/issues/27 + guard var publishedPost = post.wfPost else { return } + publishedPost.title = post.title + publishedPost.body = post.body + publishedPost.createdDate = post.createdDate + + // 2. Update the post on the server and call the handler + loggedInClient.updatePost( + postId: publishedPost.postId!, + updatedPost: publishedPost, + completion: publishHandler + ) + } + } } 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.account.currentError = AccountError.usernameNotFound } } catch WFError.unauthorized { DispatchQueue.main.async { self.account.currentError = AccountError.invalidPassword } } catch { if let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == -1003 { DispatchQueue.main.async { self.account.currentError = AccountError.serverNotFound } } } } 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() self.collections.clearUserCollection() self.store.purge() } } 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() self.collections.clearUserCollection() self.store.purge() } } 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 let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { do { let fetchedCollections = try result.get() var fetchedCollectionsArray: [PostCollection] = [] for fetchedCollection in fetchedCollections { var postCollection = PostCollection(title: fetchedCollection.title) postCollection.wfCollection = fetchedCollection fetchedCollectionsArray.append(postCollection) } DispatchQueue.main.async { self.collections = CollectionListModel(with: fetchedCollectionsArray) } } catch { print(error) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { do { let fetchedPosts = try result.get() for fetchedPost in fetchedPosts { var post: Post if let matchingAlias = fetchedPost.collectionAlias { let postCollection = ( collections.userCollections.filter { $0.wfCollection?.alias == matchingAlias } ).first post = Post(wfPost: fetchedPost, in: postCollection ?? draftsCollection) } else { post = Post(wfPost: fetchedPost) } DispatchQueue.main.async { self.store.add(post) } } } catch { print(error) } } + + func publishHandler(result: Result) { + do { + let wfPost = try result.get() + let foundPostIndex = store.posts.firstIndex(where: { + $0.title == wfPost.title && $0.body == wfPost.body + }) + guard let index = foundPostIndex else { return } + DispatchQueue.main.async { + self.store.posts[index].wfPost = wfPost + } + } catch { + 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/PostEditor/PostEditorView.swift b/Shared/PostEditor/PostEditorView.swift index 5d6cb78..e8a18f4 100644 --- a/Shared/PostEditor/PostEditorView.swift +++ b/Shared/PostEditor/PostEditorView.swift @@ -1,73 +1,74 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: Post @State private var isNewPost = false var body: some View { VStack { TextEditor(text: $post.title) .font(.title) .frame(height: 100) .onChange(of: post.title) { _ in if post.status == .published { post.status = .edited } } TextEditor(text: $post.body) .font(.body) .onChange(of: post.body) { _ in if post.status == .published { post.status = .edited } } } .padding() .toolbar { ToolbarItem(placement: .status) { PostStatusBadgeView(post: post) } ToolbarItem(placement: .primaryAction) { Button(action: { + model.publish(post: post) post.status = .published }, label: { Image(systemName: "paperplane") }) } } .onAppear(perform: { checkIfNewPost() if self.isNewPost { addNewPostToStore() } }) } private func checkIfNewPost() { self.isNewPost = !model.store.posts.contains(where: { $0.id == post.id }) } private func addNewPostToStore() { withAnimation { model.store.add(post) self.isNewPost = false } } } struct PostEditorView_NewDraftPreviews: PreviewProvider { static var previews: some View { PostEditorView(post: Post()) .environmentObject(WriteFreelyModel()) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { PostEditorView(post: testPostData[0]) .environmentObject(WriteFreelyModel()) } } diff --git a/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist b/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist index 6cd8075..2723ebe 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist @@ -1,19 +1,19 @@ SchemeUserState WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ orderHint - 1 + 0 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 0 + 1