diff --git a/Shared/Models/PostCollection.swift b/Shared/Models/PostCollection.swift index 1404b11..8801cff 100644 --- a/Shared/Models/PostCollection.swift +++ b/Shared/Models/PostCollection.swift @@ -1,15 +1,23 @@ import Foundation +import WriteFreely -struct PostCollection: Identifiable, Hashable { +struct PostCollection: Identifiable { let id = UUID() let title: String + var wfCollection: WFCollection? +} + +extension PostCollection { + static func == (lhs: PostCollection, rhs: PostCollection) -> Bool { + return lhs.id == rhs.id + } } let allPostsCollection = PostCollection(title: "All Posts") let draftsCollection = PostCollection(title: "Drafts") #if DEBUG let userCollection1 = PostCollection(title: "Collection 1") let userCollection2 = PostCollection(title: "Collection 2") let userCollection3 = PostCollection(title: "Collection 3") #endif diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 18e2ebc..b54ac8a 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,184 +1,210 @@ 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) + } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() + fetchUserCollections() 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() } } 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() } } 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) + } + } } 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 aba7984..6422683 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,24 +1,26 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { SidebarView() PostListView(selectedCollection: allPostsCollection) Text("Select a post, or create a new draft.") .foregroundColor(.secondary) } .environmentObject(model) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() - .environmentObject(WriteFreelyModel()) + let model = WriteFreelyModel() + model.collections = CollectionListModel(with: [userCollection1, userCollection2, userCollection3]) + return ContentView() + .environmentObject(model) } } diff --git a/Shared/Navigation/SidebarView.swift b/Shared/Navigation/SidebarView.swift index 41d67d7..a95ee08 100644 --- a/Shared/Navigation/SidebarView.swift +++ b/Shared/Navigation/SidebarView.swift @@ -1,13 +1,16 @@ import SwiftUI struct SidebarView: View { var body: some View { CollectionListView() } } struct SidebarView_Previews: PreviewProvider { static var previews: some View { - SidebarView() + let model = WriteFreelyModel() + model.collections = CollectionListModel(with: [userCollection1, userCollection2, userCollection3]) + return SidebarView() + .environmentObject(model) } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index 8c07e46..64c7c54 100644 --- a/Shared/PostCollection/CollectionListModel.swift +++ b/Shared/PostCollection/CollectionListModel.swift @@ -1,18 +1,18 @@ import SwiftUI -struct CollectionListModel { +class CollectionListModel: ObservableObject { private(set) var userCollections: [PostCollection] = [] - private(set) var collectionsList: [PostCollection] - - init() { - collectionsList = [ allPostsCollection, draftsCollection ] - - #if DEBUG - userCollections = [ userCollection1, userCollection2, userCollection3 ] - #endif + @Published private(set) var collectionsList: [PostCollection] = [ allPostsCollection, draftsCollection ] + init(with userCollections: [PostCollection]) { for userCollection in userCollections { - collectionsList.append(userCollection) + self.userCollections.append(userCollection) } + collectionsList.append(contentsOf: self.userCollections) + } + + func clearUserCollection() { + userCollections = [] + collectionsList = [ allPostsCollection, draftsCollection ] } } diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 90ef67e..1e06865 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,25 +1,28 @@ import SwiftUI struct CollectionListView: View { - private var collections = CollectionListModel() + @EnvironmentObject var model: WriteFreelyModel var body: some View { List { - ForEach(collections.collectionsList) { collection in + ForEach(model.collections.collectionsList) { collection in NavigationLink( destination: PostListView(selectedCollection: collection) ) { Text(collection.title) } } } .navigationTitle("Collections") .listStyle(SidebarListStyle()) } } struct CollectionSidebar_Previews: PreviewProvider { static var previews: some View { - CollectionListView() + let model = WriteFreelyModel() + model.collections = CollectionListModel(with: [userCollection1, userCollection2, userCollection3]) + return CollectionListView() + .environmentObject(model) } } diff --git a/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist b/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist index 2723ebe..6cd8075 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 - 0 + 1 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 1 + 0