Page MenuHomeMusing Studio

No OneTemporary

diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift
index 8613d6b..0e2f0cf 100644
--- a/Shared/Account/AccountLogoutView.swift
+++ b/Shared/Account/AccountLogoutView.swift
@@ -1,59 +1,58 @@
import SwiftUI
struct AccountLogoutView: View {
@EnvironmentObject var model: WriteFreelyModel
- @Environment(\.managedObjectContext) var moc
@State private var isPresentingLogoutConfirmation: Bool = false
@State private var editedPostsWarningString: String = ""
var body: some View {
VStack {
Spacer()
VStack {
Text("Logged in as \(model.account.username)")
Text("on \(model.account.server)")
}
Spacer()
Button(action: logoutHandler, label: {
Text("Log Out")
})
}
.actionSheet(isPresented: $isPresentingLogoutConfirmation, content: {
ActionSheet(
title: Text("Log Out?"),
message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"),
buttons: [
.destructive(Text("Log Out"), action: {
model.logout()
}),
.cancel()
]
)
})
}
func logoutHandler() {
let request = WFAPost.createFetchRequest()
request.predicate = NSPredicate(format: "status == %i", 1)
do {
let editedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
if editedPosts.count == 1 {
editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited post. "
}
if editedPosts.count > 1 {
editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. "
}
} catch {
print("Error: failed to fetch cached posts")
}
self.isPresentingLogoutConfirmation = true
}
}
struct AccountLogoutView_Previews: PreviewProvider {
static var previews: some View {
AccountLogoutView()
.environmentObject(WriteFreelyModel())
}
}
diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift
index 89cffea..7a6962d 100644
--- a/Shared/Models/WriteFreelyModel.swift
+++ b/Shared/Models/WriteFreelyModel.swift
@@ -1,519 +1,541 @@
import Foundation
import WriteFreely
import Security
import Network
// MARK: - WriteFreelyModel
class WriteFreelyModel: ObservableObject {
@Published var account = AccountModel()
@Published var preferences = PreferencesModel()
@Published var posts = PostListModel()
@Published var editor = PostEditorModel()
@Published var isLoggingIn: Bool = false
@Published var isProcessingRequest: Bool = false
@Published var hasNetworkConnection: Bool = true
@Published var selectedPost: WFAPost?
@Published var isPresentingDeleteAlert: Bool = false
@Published var isPresentingLoginErrorAlert: Bool = false
@Published var isPresentingNetworkErrorAlert: Bool = false
@Published var postToDelete: WFAPost?
#if os(iOS)
@Published var isPresentingSettingsView: Bool = false
#endif
var loginErrorMessage: String?
// swiftlint:disable line_length
let helpURL = URL(string: "https://discuss.write.as/c/help/5")!
let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")!
let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")!
// swiftlint:enable line_length
private var client: WFClient?
private let defaults = UserDefaults.standard
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
init() {
DispatchQueue.main.async {
self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey)
self.preferences.font = self.defaults.integer(forKey: self.preferences.defaultFontIntegerKey)
self.account.restoreState()
if self.account.isLoggedIn {
guard let serverURL = URL(string: self.account.server) else {
print("Server URL not found")
return
}
guard let token = self.fetchTokenFromKeychain(
username: self.account.username,
server: self.account.server
) else {
print("Could not fetch token from Keychain")
return
}
self.account.login(WFUser(token: token, username: self.account.username))
self.client = WFClient(for: serverURL)
self.client?.user = self.account.user
self.fetchUserCollections()
self.fetchUserPosts()
}
}
monitor.pathUpdateHandler = { path in
DispatchQueue.main.async {
self.hasNetworkConnection = path.status == .satisfied
}
}
monitor.start(queue: queue)
}
}
// MARK: - WriteFreelyModel API
extension WriteFreelyModel {
func login(to server: URL, as username: String, password: String) {
if !hasNetworkConnection {
isPresentingNetworkErrorAlert = true
return
}
let secureProtocolPrefix = "https://"
let insecureProtocolPrefix = "http://"
var serverString = server.absoluteString
// If there's neither an http or https prefix, prepend "https://" to the server string.
if !(serverString.hasPrefix(secureProtocolPrefix) || serverString.hasPrefix(insecureProtocolPrefix)) {
serverString = secureProtocolPrefix + serverString
}
// If the server string is prefixed with http, upgrade to https before attempting to login.
if serverString.hasPrefix(insecureProtocolPrefix) {
serverString = serverString.replacingOccurrences(of: insecureProtocolPrefix, with: secureProtocolPrefix)
}
isLoggingIn = true
var serverURL = URL(string: serverString)!
if !serverURL.path.isEmpty {
serverURL.deleteLastPathComponent()
}
account.server = serverURL.absoluteString
client = WFClient(for: serverURL)
client?.login(username: username, password: password, completion: loginHandler)
}
func logout() {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else {
do {
try purgeTokenFromKeychain(username: account.username, server: account.server)
account.logout()
} catch {
fatalError("Failed to log out persisted state")
}
return
}
loggedInClient.logout(completion: logoutHandler)
}
func fetchUserCollections() {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.isProcessingRequest = true
}
loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler)
}
func fetchUserPosts() {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.isProcessingRequest = true
}
loggedInClient.getPosts(completion: fetchUserPostsHandler)
}
func publish(post: WFAPost) {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.isProcessingRequest = true
}
if post.language == nil {
if let languageCode = Locale.current.languageCode {
post.language = languageCode
post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft
}
}
var wfPost = WFPost(
body: post.body,
title: post.title.isEmpty ? "" : post.title,
appearance: post.appearance,
language: post.language,
rtl: post.rtl,
createdDate: post.createdDate
)
if let existingPostId = post.postId {
// This is an existing post.
wfPost.postId = post.postId
wfPost.slug = post.slug
wfPost.updatedDate = post.updatedDate
wfPost.collectionAlias = post.collectionAlias
loggedInClient.updatePost(
postId: existingPostId,
updatedPost: wfPost,
completion: publishHandler
)
} else {
// This is a new local draft.
loggedInClient.createPost(
post: wfPost, in: post.collectionAlias, completion: publishHandler
)
}
}
func updateFromServer(post: WFAPost) {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else { return }
guard let postId = post.postId else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.selectedPost = post
self.isProcessingRequest = true
}
loggedInClient.getPost(byId: postId, completion: updateFromServerHandler)
}
func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) {
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client,
let postId = post.postId else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.isProcessingRequest = true
}
selectedPost = post
post.collectionAlias = newCollection?.alias
loggedInClient.movePost(postId: postId, to: newCollection?.alias, completion: movePostHandler)
}
}
private extension WriteFreelyModel {
func loginHandler(result: Result<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 {
print(error)
}
}
func fetchUserPostsHandler(result: Result<[WFPost], Error>) {
// We're done with the network request.
DispatchQueue.main.async {
self.isProcessingRequest = false
}
+ let request = WFAPost.createFetchRequest()
do {
- var postsToDelete = posts.userPosts.filter { $0.status != PostStatus.local.rawValue }
- let fetchedPosts = try result.get()
- for fetchedPost in fetchedPosts {
- if let managedPost = posts.userPosts.first(where: { $0.postId == fetchedPost.postId }) {
- managedPost.wasDeletedFromServer = false
- if let fetchedPostUpdatedDate = fetchedPost.updatedDate,
- let localPostUpdatedDate = managedPost.updatedDate {
- managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate
+ let locallyCachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
+ do {
+ var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue }
+ let fetchedPosts = try result.get()
+ for fetchedPost in fetchedPosts {
+ if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) {
+ DispatchQueue.main.async {
+ managedPost.wasDeletedFromServer = false
+ if let fetchedPostUpdatedDate = fetchedPost.updatedDate,
+ let localPostUpdatedDate = managedPost.updatedDate {
+ managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate
+ } else {
+ print("Error: could not determine which copy of post is newer")
+ }
+ postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId })
+ }
} else {
- print("Error: could not determine which copy of post is newer")
+ DispatchQueue.main.async {
+ let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext)
+ managedPost.postId = fetchedPost.postId
+ managedPost.slug = fetchedPost.slug
+ managedPost.appearance = fetchedPost.appearance
+ managedPost.language = fetchedPost.language
+ managedPost.rtl = fetchedPost.rtl ?? false
+ managedPost.createdDate = fetchedPost.createdDate
+ managedPost.updatedDate = fetchedPost.updatedDate
+ managedPost.title = fetchedPost.title ?? ""
+ managedPost.body = fetchedPost.body
+ managedPost.collectionAlias = fetchedPost.collectionAlias
+ managedPost.status = PostStatus.published.rawValue
+ managedPost.wasDeletedFromServer = false
+ }
}
- postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId })
- } else {
- let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext)
- managedPost.postId = fetchedPost.postId
- managedPost.slug = fetchedPost.slug
- managedPost.appearance = fetchedPost.appearance
- managedPost.language = fetchedPost.language
- managedPost.rtl = fetchedPost.rtl ?? false
- managedPost.createdDate = fetchedPost.createdDate
- managedPost.updatedDate = fetchedPost.updatedDate
- managedPost.title = fetchedPost.title ?? ""
- managedPost.body = fetchedPost.body
- managedPost.collectionAlias = fetchedPost.collectionAlias
- managedPost.status = PostStatus.published.rawValue
- managedPost.wasDeletedFromServer = false
}
- }
- for post in postsToDelete {
- post.wasDeletedFromServer = true
- }
- DispatchQueue.main.async {
- LocalStorageManager().saveContext()
- self.posts.loadCachedPosts()
+ DispatchQueue.main.async {
+ for post in postsToDelete {
+ post.wasDeletedFromServer = true
+ }
+ LocalStorageManager().saveContext()
+ }
+ } catch {
+ print(error)
}
} catch {
- print(error)
+ print("Error: Failed to fetch cached posts")
}
}
func publishHandler(result: Result<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()
- let foundPostIndex = posts.userPosts.firstIndex(where: {
- $0.title == fetchedPost.title && $0.body == fetchedPost.body
- })
- guard let index = foundPostIndex else { return }
- let cachedPost = self.posts.userPosts[index]
- cachedPost.appearance = fetchedPost.appearance
- cachedPost.body = fetchedPost.body
- cachedPost.createdDate = fetchedPost.createdDate
- cachedPost.language = fetchedPost.language
- cachedPost.postId = fetchedPost.postId
- cachedPost.rtl = fetchedPost.rtl ?? false
- cachedPost.slug = fetchedPost.slug
- cachedPost.status = PostStatus.published.rawValue
- cachedPost.title = fetchedPost.title ?? ""
- cachedPost.updatedDate = fetchedPost.updatedDate
- DispatchQueue.main.async {
- LocalStorageManager().saveContext()
+ let request = WFAPost.createFetchRequest()
+ let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body)
+ if let fetchedPostTitle = fetchedPost.title {
+ let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle)
+ request.predicate = NSCompoundPredicate(
+ andPredicateWithSubpredicates: [
+ matchTitlePredicate,
+ matchBodyPredicate
+ ]
+ )
+ } else {
+ request.predicate = matchBodyPredicate
+ }
+ do {
+ let cachedPostsResults = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
+ guard let cachedPost = cachedPostsResults.first else { return }
+ cachedPost.appearance = fetchedPost.appearance
+ cachedPost.body = fetchedPost.body
+ cachedPost.createdDate = fetchedPost.createdDate
+ cachedPost.language = fetchedPost.language
+ cachedPost.postId = fetchedPost.postId
+ cachedPost.rtl = fetchedPost.rtl ?? false
+ cachedPost.slug = fetchedPost.slug
+ cachedPost.status = PostStatus.published.rawValue
+ cachedPost.title = fetchedPost.title ?? ""
+ cachedPost.updatedDate = fetchedPost.updatedDate
+ DispatchQueue.main.async {
+ LocalStorageManager().saveContext()
+ }
+ } catch {
+ print("Error: Failed to fetch cached posts")
}
} catch {
print(error)
}
}
func updateFromServerHandler(result: Result<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()
- self.posts.loadCachedPosts()
}
} 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
}
}
diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift
index e8e50e9..09d1170 100644
--- a/Shared/PostCollection/CollectionListView.swift
+++ b/Shared/PostCollection/CollectionListView.swift
@@ -1,48 +1,47 @@
import SwiftUI
struct CollectionListView: View {
@EnvironmentObject var model: WriteFreelyModel
- @Environment(\.managedObjectContext) var moc
@FetchRequest(
entity: WFACollection.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)]
) var collections: FetchedResults<WFACollection>
var body: some View {
List {
NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: true)) {
Text("All Posts")
}
if model.account.isLoggedIn {
NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) {
Text(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")
}
Section(header: Text("Your Blogs")) {
ForEach(collections, id: \.alias) { collection in
NavigationLink(
destination: PostListView(selectedCollection: collection, showAllPosts: false)
) {
Text(collection.title)
}
}
}
}
}
.navigationTitle(
model.account.isLoggedIn ? "\(URL(string: model.account.server)?.host ?? "WriteFreely")" : "WriteFreely"
)
.listStyle(SidebarListStyle())
}
}
struct CollectionListView_LoggedOutPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let model = WriteFreelyModel()
return CollectionListView()
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift
index 774a451..e6464e4 100644
--- a/Shared/PostList/PostListModel.swift
+++ b/Shared/PostList/PostListModel.swift
@@ -1,43 +1,21 @@
import SwiftUI
import CoreData
class PostListModel: ObservableObject {
- @Published var userPosts = [WFAPost]()
-
- init() {
- loadCachedPosts()
- }
-
- func loadCachedPosts() {
- let request = WFAPost.createFetchRequest()
- let sort = NSSortDescriptor(key: "createdDate", ascending: false)
- request.sortDescriptors = [sort]
-
- userPosts = []
- do {
- let cachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
- userPosts.append(contentsOf: cachedPosts)
- } catch {
- print("Error: Failed to fetch cached posts.")
- }
- }
-
func remove(_ post: WFAPost) {
LocalStorageManager.persistentContainer.viewContext.delete(post)
LocalStorageManager().saveContext()
}
func purgePublishedPosts() {
- userPosts = []
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WFAPost")
fetchRequest.predicate = NSPredicate(format: "status != %i", 0)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try LocalStorageManager.persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest)
- loadCachedPosts()
} catch {
print("Error: Failed to purge cached posts.")
}
}
}
diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift
index bdaad24..928ec7a 100644
--- a/Shared/PostList/PostListView.swift
+++ b/Shared/PostList/PostListView.swift
@@ -1,143 +1,143 @@
import SwiftUI
struct PostListView: View {
@EnvironmentObject var model: WriteFreelyModel
- @Environment(\.managedObjectContext) var moc
+ @Environment(\.managedObjectContext) var managedObjectContext
@State var selectedCollection: WFACollection?
@State var showAllPosts: Bool = false
var body: some View {
#if os(iOS)
GeometryReader { geometry in
PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts)
.navigationTitle(
showAllPosts ? "All Posts" : selectedCollection?.title ?? (
model.account.server == "https://write.as" ? "Anonymous" : "Drafts"
)
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {
createNewLocalDraft()
}, label: {
Image(systemName: "square.and.pencil")
})
}
ToolbarItem(placement: .bottomBar) {
HStack {
Button(action: {
model.isPresentingSettingsView = true
}, label: {
Image(systemName: "gear")
})
Spacer()
Text(pluralizedPostCount(for: showPosts(for: selectedCollection)))
.foregroundColor(.secondary)
Spacer()
if model.isProcessingRequest {
ProgressView()
} else {
Button(action: {
reloadFromServer()
}, label: {
Image(systemName: "arrow.clockwise")
})
.disabled(!model.account.isLoggedIn)
}
}
.padding()
.frame(width: geometry.size.width)
}
}
}
#else //if os(macOS)
PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts)
.navigationTitle(
showAllPosts ? "All Posts" : selectedCollection?.title ?? (
model.account.server == "https://write.as" ? "Anonymous" : "Drafts"
)
)
.navigationSubtitle(pluralizedPostCount(for: showPosts(for: selectedCollection)))
.toolbar {
Button(action: {
createNewLocalDraft()
}, label: {
Image(systemName: "square.and.pencil")
})
Button(action: {
reloadFromServer()
}, label: {
Image(systemName: "arrow.clockwise")
})
.disabled(!model.account.isLoggedIn)
}
#endif
}
private func pluralizedPostCount(for posts: [WFAPost]) -> String {
if posts.count == 1 {
return "1 post"
} else {
return "\(posts.count) posts"
}
}
private func showPosts(for collection: WFACollection?) -> [WFAPost] {
if showAllPosts {
return model.posts.userPosts
} else {
if let selectedCollection = collection {
return model.posts.userPosts.filter { $0.collectionAlias == selectedCollection.alias }
} else {
return model.posts.userPosts.filter { $0.collectionAlias == nil }
}
}
}
private func reloadFromServer() {
DispatchQueue.main.async {
model.fetchUserCollections()
model.fetchUserPosts()
}
}
private func createNewLocalDraft() {
- let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext)
+ let managedPost = WFAPost(context: self.managedObjectContext)
managedPost.createdDate = Date()
managedPost.title = ""
managedPost.body = ""
managedPost.status = PostStatus.local.rawValue
managedPost.collectionAlias = nil
switch model.preferences.font {
case 1:
managedPost.appearance = "sans"
case 2:
managedPost.appearance = "wrap"
default:
managedPost.appearance = "serif"
}
if let languageCode = Locale.current.languageCode {
managedPost.language = languageCode
managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft
}
DispatchQueue.main.async {
self.selectedCollection = nil
self.showAllPosts = false
withAnimation {
self.model.selectedPost = managedPost
}
}
}
}
struct PostListView_Previews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let model = WriteFreelyModel()
return PostListView()
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift
index e2ae217..b369ba9 100644
--- a/iOS/PostEditor/PostEditorView.swift
+++ b/iOS/PostEditor/PostEditorView.swift
@@ -1,235 +1,232 @@
import SwiftUI
struct PostEditorView: View {
@EnvironmentObject var model: WriteFreelyModel
- @Environment(\.managedObjectContext) var moc
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.presentationMode) var presentationMode
@ObservedObject var post: WFAPost
@State private var updatingTitleFromServer: Bool = false
@State private var updatingBodyFromServer: Bool = false
@State private var selectedCollection: WFACollection?
@FetchRequest(
entity: WFACollection.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)]
) var collections: FetchedResults<WFACollection>
var body: some View {
VStack {
if post.hasNewerRemoteCopy {
RemoteChangePromptView(
remoteChangeType: .remoteCopyUpdated,
buttonHandler: { model.updateFromServer(post: post) }
)
} else if post.wasDeletedFromServer {
RemoteChangePromptView(
remoteChangeType: .remoteCopyDeleted,
buttonHandler: {
self.presentationMode.wrappedValue.dismiss()
DispatchQueue.main.async {
model.posts.remove(post)
}
}
)
}
PostTextEditingView(
post: _post,
updatingTitleFromServer: $updatingTitleFromServer,
updatingBodyFromServer: $updatingBodyFromServer
)
}
.navigationBarTitleDisplayMode(.inline)
.padding()
.toolbar {
ToolbarItem(placement: .principal) {
PostEditorStatusToolbarView(post: post)
}
ToolbarItem(placement: .primaryAction) {
if model.isProcessingRequest {
ProgressView()
} else {
Menu(content: {
if post.status == PostStatus.local.rawValue {
Menu(content: {
Label("Publish to…", systemImage: "paperplane")
Button(action: {
if model.account.isLoggedIn {
post.collectionAlias = nil
publishPost()
} else {
self.model.isPresentingSettingsView = true
}
}, label: {
Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
})
ForEach(collections) { collection in
Button(action: {
if model.account.isLoggedIn {
post.collectionAlias = collection.alias
publishPost()
} else {
self.model.isPresentingSettingsView = true
}
}, label: {
Text(" \(collection.title)")
})
}
}, label: {
Label("Publish…", systemImage: "paperplane")
})
} else {
Button(action: {
if model.account.isLoggedIn {
publishPost()
} else {
self.model.isPresentingSettingsView = true
}
}, label: {
Label("Publish", systemImage: "paperplane")
})
.disabled(post.status == PostStatus.published.rawValue || post.body.count == 0)
}
Button(action: {
sharePost()
}, label: {
Label("Share", systemImage: "square.and.arrow.up")
})
.disabled(post.postId == nil)
// Button(action: {
// print("Tapped 'Delete...' button")
// }, label: {
// Label("Delete…", systemImage: "trash")
// })
if model.account.isLoggedIn && post.status != PostStatus.local.rawValue {
Section(header: Text("Move To Collection")) {
Label("Move to:", systemImage: "arrowshape.zigzag.right")
Picker(selection: $selectedCollection, label: Text("Move to…")) {
Text(
" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")"
).tag(nil as WFACollection?)
ForEach(collections) { collection in
Text(" \(collection.title)").tag(collection as WFACollection?)
}
}
}
}
}, label: {
Image(systemName: "ellipsis.circle")
})
}
}
}
.onChange(of: post.hasNewerRemoteCopy, perform: { _ in
if !post.hasNewerRemoteCopy {
updatingTitleFromServer = true
updatingBodyFromServer = true
}
})
.onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
if post.collectionAlias == newCollection?.alias {
return
} else {
post.collectionAlias = newCollection?.alias
model.move(post: post, from: selectedCollection, to: newCollection)
}
})
.onAppear(perform: {
self.selectedCollection = collections.first { $0.alias == post.collectionAlias }
})
.onDisappear(perform: {
if post.title.count == 0
&& post.body.count == 0
&& post.status == PostStatus.local.rawValue
&& post.updatedDate == nil
&& post.postId == nil {
DispatchQueue.main.async {
model.posts.remove(post)
- model.posts.loadCachedPosts()
}
} else if post.status != PostStatus.published.rawValue {
DispatchQueue.main.async {
LocalStorageManager().saveContext()
}
}
})
}
private func publishPost() {
DispatchQueue.main.async {
LocalStorageManager().saveContext()
- model.posts.loadCachedPosts()
model.publish(post: post)
}
#if os(iOS)
self.hideKeyboard()
#endif
}
private func sharePost() {
// If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early.
guard let postId = post.postId else { return }
var urlString: String
if let postSlug = post.slug,
let postCollectionAlias = post.collectionAlias {
// This post is in a collection, so share the URL as server/collectionAlias/postSlug.
urlString = "\(model.account.server)/\((postCollectionAlias))/\((postSlug))"
} else {
// This is a draft post, so share the URL as server/postID
urlString = "\(model.account.server)/\((postId))"
}
guard let data = URL(string: urlString) else { return }
let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil)
UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil)
if UIDevice.current.userInterfaceIdiom == .pad {
activityView.popoverPresentationController?.permittedArrowDirections = .up
activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first
activityView.popoverPresentationController?.sourceRect = CGRect(
x: UIScreen.main.bounds.width,
y: -125,
width: 200,
height: 200
)
}
}
}
struct PostEditorView_EmptyPostPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let testPost = WFAPost(context: context)
testPost.createdDate = Date()
testPost.appearance = "norm"
let model = WriteFreelyModel()
return PostEditorView(post: testPost)
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
struct PostEditorView_ExistingPostPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let testPost = WFAPost(context: context)
testPost.title = "Test Post Title"
testPost.body = "Here's some cool sample body text."
testPost.createdDate = Date()
testPost.appearance = "code"
testPost.hasNewerRemoteCopy = true
let model = WriteFreelyModel()
return PostEditorView(post: testPost)
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
diff --git a/macOS/PostEditor/PostEditorView.swift b/macOS/PostEditor/PostEditorView.swift
index 1b023c2..6d2b238 100644
--- a/macOS/PostEditor/PostEditorView.swift
+++ b/macOS/PostEditor/PostEditorView.swift
@@ -1,101 +1,99 @@
import SwiftUI
struct PostEditorView: View {
private let bodyLineSpacing: CGFloat = 17 * 0.5
@EnvironmentObject var model: WriteFreelyModel
@ObservedObject var post: WFAPost
@State private var isHovering: Bool = false
@State private var updatingTitleFromServer: Bool = false
@State private var updatingBodyFromServer: Bool = false
var body: some View {
PostTextEditingView(
post: post,
updatingTitleFromServer: $updatingTitleFromServer,
updatingBodyFromServer: $updatingBodyFromServer
)
.padding()
.toolbar {
ToolbarItem(placement: .status) {
PostEditorStatusToolbarView(post: post)
}
ToolbarItem(placement: .primaryAction) {
Button(action: {
if model.account.isLoggedIn {
publishPost()
} else {
let mainMenu = NSApplication.shared.mainMenu
let appMenuItem = mainMenu?.item(withTitle: "WriteFreely")
let prefsItem = appMenuItem?.submenu?.item(withTitle: "Preferences…")
NSApplication.shared.sendAction(prefsItem!.action!, to: prefsItem?.target, from: nil)
}
}, label: {
Image(systemName: "paperplane")
})
.disabled(post.status == PostStatus.published.rawValue || post.body.count == 0)
}
}
.onChange(of: post.hasNewerRemoteCopy, perform: { _ in
if post.status == PostStatus.edited.rawValue && !post.hasNewerRemoteCopy {
post.status = PostStatus.published.rawValue
}
})
.onDisappear(perform: {
if post.title.count == 0
&& post.body.count == 0
&& post.status == PostStatus.local.rawValue
&& post.updatedDate == nil
&& post.postId == nil {
DispatchQueue.main.async {
model.posts.remove(post)
- model.posts.loadCachedPosts()
}
} else if post.status != PostStatus.published.rawValue {
DispatchQueue.main.async {
LocalStorageManager().saveContext()
}
}
})
}
private func publishPost() {
DispatchQueue.main.async {
LocalStorageManager().saveContext()
- model.posts.loadCachedPosts()
model.publish(post: post)
}
}
}
struct PostEditorView_EmptyPostPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let testPost = WFAPost(context: context)
testPost.createdDate = Date()
testPost.appearance = "norm"
let model = WriteFreelyModel()
return PostEditorView(post: testPost)
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
struct PostEditorView_ExistingPostPreviews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let testPost = WFAPost(context: context)
testPost.title = "Test Post Title"
testPost.body = "Here's some cool sample body text."
testPost.createdDate = Date()
testPost.appearance = "code"
let model = WriteFreelyModel()
return PostEditorView(post: testPost)
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Jan 31, 4:40 PM (18 h, 10 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3145883

Event Timeline