mirror of
https://github.com/writeas/writefreely-swiftui-multiplatform.git
synced 2024-11-15 01:11:02 +00:00
Merge pull request #169 from writeas/remove-trailing-slash-in-serverURL
Remove trailing slash in server URL
This commit is contained in:
commit
3d5da9c37b
@ -58,10 +58,27 @@ struct AccountLoginView: View {
|
||||
#if os(iOS)
|
||||
hideKeyboard()
|
||||
#endif
|
||||
// If the server string is not prefixed with a scheme, prepend "https://" to it.
|
||||
if !(server.hasPrefix("https://") || server.hasPrefix("http://")) {
|
||||
server = "https://\(server)"
|
||||
}
|
||||
// We only need the protocol and host from the URL, so drop anything else.
|
||||
let url = URLComponents(string: server)
|
||||
if let validURL = url {
|
||||
let scheme = validURL.scheme
|
||||
let host = validURL.host
|
||||
var hostURL = URLComponents()
|
||||
hostURL.scheme = scheme
|
||||
hostURL.host = host
|
||||
server = hostURL.string ?? server
|
||||
model.login(
|
||||
to: URL(string: server)!,
|
||||
as: username, password: password
|
||||
)
|
||||
} else {
|
||||
model.loginErrorMessage = AccountError.invalidServerURL.localizedDescription
|
||||
model.isPresentingLoginErrorAlert = true
|
||||
}
|
||||
}, label: {
|
||||
Text("Log In")
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ enum AccountError: Error {
|
||||
case invalidPassword
|
||||
case usernameNotFound
|
||||
case serverNotFound
|
||||
case invalidServerURL
|
||||
}
|
||||
|
||||
extension AccountError: LocalizedError {
|
||||
@ -25,6 +26,11 @@ extension AccountError: LocalizedError {
|
||||
"Username not found. Did you use your email address by mistake?",
|
||||
comment: ""
|
||||
)
|
||||
case .invalidServerURL:
|
||||
return NSLocalizedString(
|
||||
"Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length
|
||||
comment: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
152
Shared/Extensions/WriteFreelyModel+API.swift
Normal file
152
Shared/Extensions/WriteFreelyModel+API.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import WriteFreely
|
||||
|
||||
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) {
|
||||
postToUpdate = nil
|
||||
|
||||
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.
|
||||
postToUpdate = post
|
||||
wfPost.postId = post.postId
|
||||
|
||||
loggedInClient.updatePost(
|
||||
postId: existingPostId,
|
||||
updatedPost: wfPost,
|
||||
completion: publishHandler
|
||||
)
|
||||
} else {
|
||||
// This is a new local draft.
|
||||
loggedInClient.createPost(
|
||||
post: wfPost, in: post.collectionAlias, completion: publishHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromServer(post: WFAPost) {
|
||||
if !hasNetworkConnection {
|
||||
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)
|
||||
}
|
||||
}
|
294
Shared/Extensions/WriteFreelyModel+APIHandlers.swift
Normal file
294
Shared/Extensions/WriteFreelyModel+APIHandlers.swift
Normal file
@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import WriteFreely
|
||||
|
||||
extension WriteFreelyModel {
|
||||
func loginHandler(result: Result<WFUser, Error>) {
|
||||
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<Bool, Error>) {
|
||||
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 WFError.unauthorized {
|
||||
DispatchQueue.main.async {
|
||||
self.loginErrorMessage = "Something went wrong, please try logging in again."
|
||||
self.isPresentingLoginErrorAlert = true
|
||||
}
|
||||
self.logout()
|
||||
} 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
for post in postsToDelete { post.wasDeletedFromServer = true }
|
||||
LocalStorageManager().saveContext()
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
} catch WFError.unauthorized {
|
||||
DispatchQueue.main.async {
|
||||
self.loginErrorMessage = "Something went wrong, please try logging in again."
|
||||
self.isPresentingLoginErrorAlert = true
|
||||
}
|
||||
self.logout()
|
||||
} catch {
|
||||
print("Error: Failed to fetch cached posts")
|
||||
}
|
||||
}
|
||||
|
||||
func publishHandler(result: Result<WFPost, Error>) {
|
||||
// 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 {
|
||||
updatingPost.appearance = fetchedPost.appearance
|
||||
updatingPost.body = fetchedPost.body
|
||||
updatingPost.createdDate = fetchedPost.createdDate
|
||||
updatingPost.language = fetchedPost.language
|
||||
updatingPost.postId = fetchedPost.postId
|
||||
updatingPost.rtl = fetchedPost.rtl ?? false
|
||||
updatingPost.slug = fetchedPost.slug
|
||||
updatingPost.status = PostStatus.published.rawValue
|
||||
updatingPost.title = fetchedPost.title ?? ""
|
||||
updatingPost.updatedDate = fetchedPost.updatedDate
|
||||
DispatchQueue.main.async {
|
||||
LocalStorageManager().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.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<WFPost, Error>) {
|
||||
// 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()
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
func movePostHandler(result: Result<Bool, Error>) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
53
Shared/Extensions/WriteFreelyModel+Keychain.swift
Normal file
53
Shared/Extensions/WriteFreelyModel+Keychain.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
extension WriteFreelyModel {
|
||||
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
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import Network
|
||||
|
||||
// MARK: - WriteFreelyModel
|
||||
|
||||
class WriteFreelyModel: ObservableObject {
|
||||
final class WriteFreelyModel: ObservableObject {
|
||||
@Published var account = AccountModel()
|
||||
@Published var preferences = PreferencesModel()
|
||||
@Published var posts = PostListModel()
|
||||
@ -33,11 +33,11 @@ class WriteFreelyModel: ObservableObject {
|
||||
let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")!
|
||||
// swiftlint:enable line_length
|
||||
|
||||
private var client: WFClient?
|
||||
internal var client: WFClient?
|
||||
private let defaults = UserDefaults.standard
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
private var postToUpdate: WFAPost?
|
||||
internal var postToUpdate: WFAPost?
|
||||
|
||||
init() {
|
||||
DispatchQueue.main.async {
|
||||
@ -72,500 +72,3 @@ class WriteFreelyModel: ObservableObject {
|
||||
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) {
|
||||
postToUpdate = nil
|
||||
|
||||
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.
|
||||
postToUpdate = post
|
||||
wfPost.postId = post.postId
|
||||
|
||||
loggedInClient.updatePost(
|
||||
postId: existingPostId,
|
||||
updatedPost: wfPost,
|
||||
completion: publishHandler
|
||||
)
|
||||
} else {
|
||||
// This is a new local draft.
|
||||
loggedInClient.createPost(
|
||||
post: wfPost, in: post.collectionAlias, completion: publishHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromServer(post: WFAPost) {
|
||||
if !hasNetworkConnection {
|
||||
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<WFUser, Error>) {
|
||||
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<Bool, Error>) {
|
||||
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 WFError.unauthorized {
|
||||
DispatchQueue.main.async {
|
||||
self.loginErrorMessage = "Something went wrong, please try logging in again."
|
||||
self.isPresentingLoginErrorAlert = true
|
||||
}
|
||||
self.logout()
|
||||
} 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
for post in postsToDelete { post.wasDeletedFromServer = true }
|
||||
LocalStorageManager().saveContext()
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
} catch WFError.unauthorized {
|
||||
DispatchQueue.main.async {
|
||||
self.loginErrorMessage = "Something went wrong, please try logging in again."
|
||||
self.isPresentingLoginErrorAlert = true
|
||||
}
|
||||
self.logout()
|
||||
} catch {
|
||||
print("Error: Failed to fetch cached posts")
|
||||
}
|
||||
}
|
||||
|
||||
func publishHandler(result: Result<WFPost, Error>) {
|
||||
// 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 {
|
||||
updatingPost.appearance = fetchedPost.appearance
|
||||
updatingPost.body = fetchedPost.body
|
||||
updatingPost.createdDate = fetchedPost.createdDate
|
||||
updatingPost.language = fetchedPost.language
|
||||
updatingPost.postId = fetchedPost.postId
|
||||
updatingPost.rtl = fetchedPost.rtl ?? false
|
||||
updatingPost.slug = fetchedPost.slug
|
||||
updatingPost.status = PostStatus.published.rawValue
|
||||
updatingPost.title = fetchedPost.title ?? ""
|
||||
updatingPost.updatedDate = fetchedPost.updatedDate
|
||||
DispatchQueue.main.async {
|
||||
LocalStorageManager().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.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<WFPost, Error>) {
|
||||
// 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()
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
func movePostHandler(result: Result<Bool, Error>) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +59,12 @@
|
||||
17A67CAF251A5DD7002F163D /* PostEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A67CAE251A5DD7002F163D /* PostEditorView.swift */; };
|
||||
17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */; };
|
||||
17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A6325489E900057D763 /* PostBodyTextView.swift */; };
|
||||
17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; };
|
||||
17B37C4C25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; };
|
||||
17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */; };
|
||||
17B37C5725C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */; };
|
||||
17B37C5D25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */; };
|
||||
17B37C5E25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */; };
|
||||
17B3E965250FAA9000EE9748 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */; };
|
||||
17B5103B2515448D00E9631F /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 17B5103A2515448D00E9631F /* Credits.rtf */; };
|
||||
17B996D82502D23E0017B536 /* WFAPost+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B996D62502D23E0017B536 /* WFAPost+CoreDataClass.swift */; };
|
||||
@ -155,6 +161,9 @@
|
||||
17A67CAE251A5DD7002F163D /* PostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorView.swift; sourceTree = "<group>"; };
|
||||
17AD0A5D25489E810057D763 /* PostTitleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTitleTextView.swift; sourceTree = "<group>"; };
|
||||
17AD0A6325489E900057D763 /* PostBodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBodyTextView.swift; sourceTree = "<group>"; };
|
||||
17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+Keychain.swift"; sourceTree = "<group>"; };
|
||||
17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+API.swift"; sourceTree = "<group>"; };
|
||||
17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+APIHandlers.swift"; sourceTree = "<group>"; };
|
||||
17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
17B5103A2515448D00E9631F /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
|
||||
17B68D4F25A4FED2005ED37C /* Sparkle-License.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Sparkle-License.txt"; sourceTree = "<group>"; };
|
||||
@ -259,8 +268,11 @@
|
||||
1756AE7F24CB841200FD7257 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
17C42E6F250AA12200072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift */,
|
||||
17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */,
|
||||
17C42E6F250AA12200072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift */,
|
||||
17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */,
|
||||
17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */,
|
||||
17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@ -723,14 +735,17 @@
|
||||
files = (
|
||||
17DF32AC24C87D3500BCE2E3 /* ContentView.swift in Sources */,
|
||||
173E19D1254318F600440F0F /* RemoteChangePromptView.swift in Sources */,
|
||||
17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */,
|
||||
17C42E622507D8E600072984 /* PostStatus.swift in Sources */,
|
||||
1756DBBA24FED45500207AB8 /* LocalStorageManager.swift in Sources */,
|
||||
1756AE8124CB844500FD7257 /* View+Keyboard.swift in Sources */,
|
||||
17C42E652509237800072984 /* PostListFilteredView.swift in Sources */,
|
||||
170DFA34251BBC44001D82A0 /* PostEditorModel.swift in Sources */,
|
||||
17120DAC24E1B99F002B9F6C /* AccountLoginView.swift in Sources */,
|
||||
17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */,
|
||||
17480CA5251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */,
|
||||
17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */,
|
||||
17B37C5D25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */,
|
||||
17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */,
|
||||
17120DA924E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */,
|
||||
171BFDFA24D4AF8300888236 /* CollectionListView.swift in Sources */,
|
||||
@ -786,15 +801,18 @@
|
||||
171BFDFB24D4AF8300888236 /* CollectionListView.swift in Sources */,
|
||||
17A67CAF251A5DD7002F163D /* PostEditorView.swift in Sources */,
|
||||
17DF32AB24C87D3500BCE2E3 /* WriteFreely_MultiPlatformApp.swift in Sources */,
|
||||
17B37C5725C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */,
|
||||
17A5388C24DDC83F00DEFF9A /* AccountModel.swift in Sources */,
|
||||
17B996D92502D23E0017B536 /* WFAPost+CoreDataClass.swift in Sources */,
|
||||
1756DBB824FED3A400207AB8 /* LocalStorageModel.xcdatamodeld in Sources */,
|
||||
17B37C4C25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */,
|
||||
17A5389324DDED0000DEFF9A /* PreferencesView.swift in Sources */,
|
||||
1756AE6F24CB255B00FD7257 /* PostListModel.swift in Sources */,
|
||||
1756DC0224FEE18400207AB8 /* WFACollection+CoreDataClass.swift in Sources */,
|
||||
1756DBB424FECDBB00207AB8 /* PostEditorStatusToolbarView.swift in Sources */,
|
||||
17A5388F24DDEC7400DEFF9A /* AccountView.swift in Sources */,
|
||||
1780F6EF25895EDB00FE45FF /* PostCommands.swift in Sources */,
|
||||
17B37C5E25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */,
|
||||
170DFA35251BBC44001D82A0 /* PostEditorModel.swift in Sources */,
|
||||
1756AE7524CB26FA00FD7257 /* PostCellView.swift in Sources */,
|
||||
17A5388824DDA31F00DEFF9A /* MacAccountView.swift in Sources */,
|
||||
|
@ -69,6 +69,7 @@ struct PostBodyTextView: UIViewRepresentable {
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.isScrollEnabled = true
|
||||
textView.alwaysBounceVertical = false
|
||||
textView.smartDashesType = .no
|
||||
|
||||
context.coordinator.textView = textView
|
||||
textView.delegate = context.coordinator
|
||||
|
Loading…
Reference in New Issue
Block a user