Page Menu
Home
Musing Studio
Search
Configure Global Search
Log In
Files
F10588183
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
28 KB
Subscribers
None
View Options
diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift
index 8198989..3d64160 100644
--- a/Shared/Models/WriteFreelyModel.swift
+++ b/Shared/Models/WriteFreelyModel.swift
@@ -1,397 +1,397 @@
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 isLoggingIn: Bool = false
@Published var hasNetworkConnection: Bool = false
@Published var selectedPost: WFAPost?
@Published var isPresentingDeleteAlert: Bool = false
@Published var postToDelete: WFAPost?
#if os(iOS)
@Published var isPresentingSettingsView: Bool = false
#endif
// swiftlint:disable line_length
let helpURL = URL(string: "https://discuss.write.as/c/help/5")!
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) {
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: WFAPost) {
guard let loggedInClient = client else { return }
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) {
guard let loggedInClient = client else { return }
guard let postId = post.postId else { return }
DispatchQueue.main.async {
self.selectedPost = post
}
if let postCollectionAlias = post.collectionAlias,
let postSlug = post.slug {
loggedInClient.getPost(bySlug: postSlug, from: postCollectionAlias, completion: updateFromServerHandler)
} else {
loggedInClient.getPost(byId: postId, completion: updateFromServerHandler)
}
}
}
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.account.currentError = AccountError.usernameNotFound
}
} catch WFError.unauthorized {
DispatchQueue.main.async {
self.account.currentError = AccountError.invalidPassword
}
} catch {
if (error as NSError).domain == NSURLErrorDomain,
(error as NSError).code == -1003 {
DispatchQueue.main.async {
self.account.currentError = AccountError.serverNotFound
}
}
}
}
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.purgeAllPosts()
}
} 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.purgeAllPosts()
}
} 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>) {
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>) {
do {
- var postsToDelete = posts.userPosts
+ 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
} else {
print("Error: could not determine which copy of post is newer")
}
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()
}
} catch {
print(error)
}
}
func publishHandler(result: Result<WFPost, Error>) {
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.collectionAlias = fetchedPost.collectionAlias
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)
}
}
func updateFromServerHandler(result: Result<WFPost, Error>) {
// ⚠️ 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)
}
}
}
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/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift
index ed02d54..3dc492b 100644
--- a/Shared/PostEditor/PostEditorStatusToolbarView.swift
+++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift
@@ -1,139 +1,152 @@
import SwiftUI
struct PostEditorStatusToolbarView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.presentationMode) var presentationMode
#endif
@EnvironmentObject var model: WriteFreelyModel
@ObservedObject var post: WFAPost
var body: some View {
if post.hasNewerRemoteCopy {
#if os(iOS)
if horizontalSizeClass == .compact {
VStack {
PostStatusBadgeView(post: post)
HStack {
Text("⚠️ Newer copy on server. Replace local copy?")
.font(.caption)
.foregroundColor(.secondary)
Button(action: {
model.updateFromServer(post: post)
}, label: {
Image(systemName: "square.and.arrow.down")
})
}
.padding(.bottom)
}
.padding(.top)
} else {
HStack {
PostStatusBadgeView(post: post)
.padding(.trailing)
Text("⚠️ Newer copy on server. Replace local copy?")
.font(.callout)
.foregroundColor(.secondary)
Button(action: {
model.updateFromServer(post: post)
}, label: {
Image(systemName: "square.and.arrow.down")
})
}
}
#else
HStack {
PostStatusBadgeView(post: post)
.padding(.trailing)
Text("⚠️ Newer copy on server. Replace local copy?")
.font(.callout)
.foregroundColor(.secondary)
Button(action: {
model.updateFromServer(post: post)
}, label: {
Image(systemName: "square.and.arrow.down")
})
}
#endif
- } else if post.wasDeletedFromServer {
+ } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue {
#if os(iOS)
if horizontalSizeClass == .compact {
VStack {
PostStatusBadgeView(post: post)
HStack {
Text("‼️ Post deleted from server. Delete local copy?")
.font(.caption)
.foregroundColor(.secondary)
Button(action: {
self.presentationMode.wrappedValue.dismiss()
model.selectedPost = nil
model.posts.remove(post)
}, label: {
Image(systemName: "trash")
})
}
.padding(.bottom)
}
.padding(.top)
} else {
HStack {
PostStatusBadgeView(post: post)
.padding(.trailing)
Text("‼️ Post deleted from server. Delete local copy?")
.font(.callout)
.foregroundColor(.secondary)
Button(action: {
self.presentationMode.wrappedValue.dismiss()
model.selectedPost = nil
model.posts.remove(post)
}, label: {
Image(systemName: "trash")
})
}
}
#else
HStack {
PostStatusBadgeView(post: post)
.padding(.trailing)
Text("‼️ Post deleted from server. Delete local copy?")
.font(.callout)
.foregroundColor(.secondary)
Button(action: {
model.selectedPost = nil
model.posts.remove(post)
}, label: {
Image(systemName: "trash")
})
}
#endif
} else {
PostStatusBadgeView(post: post)
}
}
}
struct PESTView_StandardPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let model = WriteFreelyModel()
let testPost = WFAPost(context: context)
testPost.status = PostStatus.published.rawValue
return PostEditorStatusToolbarView(post: testPost)
.environmentObject(model)
}
}
struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let model = WriteFreelyModel()
let testPost = WFAPost(context: context)
testPost.status = PostStatus.published.rawValue
testPost.hasNewerRemoteCopy = true
return PostEditorStatusToolbarView(post: testPost)
.environmentObject(model)
}
}
+
+struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider {
+ static var previews: some View {
+ let context = LocalStorageManager.persistentContainer.viewContext
+ let model = WriteFreelyModel()
+ let testPost = WFAPost(context: context)
+ testPost.status = PostStatus.published.rawValue
+ testPost.wasDeletedFromServer = true
+
+ return PostEditorStatusToolbarView(post: testPost)
+ .environmentObject(model)
+ }
+}
diff --git a/Shared/PostEditor/PostEditorView.swift b/Shared/PostEditor/PostEditorView.swift
index 6182e87..8ef4eb0 100644
--- a/Shared/PostEditor/PostEditorView.swift
+++ b/Shared/PostEditor/PostEditorView.swift
@@ -1,81 +1,117 @@
import SwiftUI
struct PostEditorView: View {
@EnvironmentObject var model: WriteFreelyModel
@ObservedObject var post: WFAPost
var body: some View {
VStack {
- TextEditor(text: $post.title)
- .font(.title)
- .frame(height: 100)
- .onChange(of: post.title) { _ in
- if post.status == PostStatus.published.rawValue {
- post.status = PostStatus.edited.rawValue
+ switch post.appearance {
+ case "sans":
+ TextEditor(text: $post.title)
+ .font(.custom("OpenSans-Regular", size: 26, relativeTo: Font.TextStyle.largeTitle))
+ .frame(height: 100)
+ .onChange(of: post.title) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
}
- }
- TextEditor(text: $post.body)
- .font(.body)
- .onChange(of: post.body) { _ in
- if post.status == PostStatus.published.rawValue {
- post.status = PostStatus.edited.rawValue
+ TextEditor(text: $post.body)
+ .font(.custom("OpenSans-Regular", size: 17, relativeTo: Font.TextStyle.body))
+ .onChange(of: post.body) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
}
- }
+ case "wrap", "mono", "code":
+ TextEditor(text: $post.title)
+ .font(.custom("Hack", size: 26, relativeTo: Font.TextStyle.largeTitle))
+ .frame(height: 100)
+ .onChange(of: post.title) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
+ }
+ TextEditor(text: $post.body)
+ .font(.custom("Hack", size: 17, relativeTo: Font.TextStyle.body))
+ .onChange(of: post.body) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
+ }
+ default:
+ TextEditor(text: $post.title)
+ .font(.custom("Lora", size: 26, relativeTo: Font.TextStyle.largeTitle))
+ .frame(height: 100)
+ .onChange(of: post.title) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
+ }
+ TextEditor(text: $post.body)
+ .font(.custom("Lora", size: 17, relativeTo: Font.TextStyle.body))
+ .onChange(of: post.body) { _ in
+ if post.status == PostStatus.published.rawValue {
+ post.status = PostStatus.edited.rawValue
+ }
+ }
+ }
}
.padding()
.toolbar {
ToolbarItem(placement: .status) {
PostEditorStatusToolbarView(post: post)
}
ToolbarItem(placement: .primaryAction) {
Button(action: {
publishPost()
}, label: {
Image(systemName: "paperplane")
})
.disabled(
post.status == PostStatus.published.rawValue ||
!model.account.isLoggedIn ||
!model.hasNetworkConnection
)
}
}
.onChange(of: post.hasNewerRemoteCopy, perform: { _ in
if post.status == PostStatus.edited.rawValue && !post.hasNewerRemoteCopy {
post.status = PostStatus.published.rawValue
}
})
.onDisappear(perform: {
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_Previews: 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)
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Apr 25, 4:08 AM (1 d, 7 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3214866
Attached To
rWFSUI WriteFreely SwiftUI
Event Timeline
Log In to Comment