Page Menu
Home
Musing Studio
Search
Configure Global Search
Log In
Files
F10700720
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
68 KB
Subscribers
None
View Options
diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift
index 02c32f9..de7bb97 100644
--- a/Shared/Account/AccountLoginView.swift
+++ b/Shared/Account/AccountLoginView.swift
@@ -1,87 +1,89 @@
import SwiftUI
struct AccountLoginView: View {
@EnvironmentObject var model: WriteFreelyModel
@State private var alertMessage: String = ""
@State private var username: String = ""
@State private var password: String = ""
@State private var server: String = ""
var body: some View {
VStack {
Text("Log in to publish and share your posts.")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Image(systemName: "person.circle")
.foregroundColor(.gray)
#if os(iOS)
TextField("Username", text: $username)
.autocapitalization(.none)
.disableAutocorrection(true)
.textFieldStyle(RoundedBorderTextFieldStyle())
#else
TextField("Username", text: $username)
#endif
}
HStack {
Image(systemName: "lock.circle")
.foregroundColor(.gray)
#if os(iOS)
SecureField("Password", text: $password)
.autocapitalization(.none)
.disableAutocorrection(true)
.textFieldStyle(RoundedBorderTextFieldStyle())
#else
SecureField("Password", text: $password)
#endif
}
HStack {
Image(systemName: "link.circle")
.foregroundColor(.gray)
#if os(iOS)
TextField("Server URL", text: $server)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.textFieldStyle(RoundedBorderTextFieldStyle())
#else
TextField("Server URL", text: $server)
#endif
}
Spacer()
if model.isLoggingIn {
ProgressView("Logging in...")
.padding()
} else {
Button(action: {
+ #if os(iOS)
hideKeyboard()
+ #endif
model.login(
to: URL(string: server)!,
as: username, password: password
)
}, label: {
Text("Log In")
})
.disabled(
model.account.isLoggedIn || (username.isEmpty || password.isEmpty || server.isEmpty)
)
.padding()
}
}
.alert(isPresented: $model.isPresentingLoginErrorAlert) {
Alert(
title: Text("Error Logging In"),
message: Text(model.loginErrorMessage ?? "An unknown error occurred while trying to login."),
dismissButton: .default(Text("OK"))
)
}
}
}
struct AccountLoginView_Previews: PreviewProvider {
static var previews: some View {
AccountLoginView()
.environmentObject(WriteFreelyModel())
}
}
diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift
index 8613d6b..9f69a50 100644
--- a/Shared/Account/AccountLogoutView.swift
+++ b/Shared/Account/AccountLogoutView.swift
@@ -1,59 +1,89 @@
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 {
+ #if os(iOS)
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()
]
)
})
+ #else
+ VStack {
+ Spacer()
+ VStack {
+ Text("Logged in as \(model.account.username)")
+ Text("on \(model.account.server)")
+ }
+ Spacer()
+ Button(action: logoutHandler, label: {
+ Text("Log Out")
+ })
+ }
+ .sheet(isPresented: $isPresentingLogoutConfirmation) {
+ VStack {
+ Text("Log Out?")
+ .font(.title)
+ Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?")
+ HStack {
+ Button(action: model.logout, label: {
+ Text("Log Out")
+ })
+ Button(action: {
+ self.isPresentingLogoutConfirmation = false
+ }, label: {
+ Text("Cancel")
+ }).keyboardShortcut(.cancelAction)
+ }
+ }
+ }
+ #endif
}
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 ca3764b..c079cf5 100644
--- a/Shared/Models/WriteFreelyModel.swift
+++ b/Shared/Models/WriteFreelyModel.swift
@@ -1,531 +1,537 @@
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? {
- didSet {
- if let post = selectedPost {
- if post.status != PostStatus.published.rawValue {
- editor.setLastDraft(post)
- } else {
- editor.clearLastDraft()
- }
- } else {
- editor.clearLastDraft()
- }
- }
- }
+ @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/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift
index 426b112..6831b7c 100644
--- a/Shared/Navigation/ContentView.swift
+++ b/Shared/Navigation/ContentView.swift
@@ -1,91 +1,67 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var model: WriteFreelyModel
var body: some View {
NavigationView {
SidebarView()
PostListView(selectedCollection: nil, showAllPosts: model.account.isLoggedIn)
Text("Select a post, or create a new local draft.")
.foregroundColor(.secondary)
}
- .onAppear(perform: {
- if let lastDraft = self.model.editor.fetchLastDraft() {
- model.selectedPost = lastDraft
- } else {
- let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext)
- managedPost.createdDate = Date()
- managedPost.title = ""
- managedPost.body = ""
- managedPost.status = PostStatus.local.rawValue
- switch self.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
- }
- model.selectedPost = managedPost
- }
- })
.environmentObject(model)
.alert(isPresented: $model.isPresentingDeleteAlert) {
Alert(
title: Text("Delete Post?"),
message: Text("This action cannot be undone."),
primaryButton: .destructive(Text("Delete"), action: {
if let postToDelete = model.postToDelete {
model.selectedPost = nil
- withAnimation {
+ DispatchQueue.main.async {
model.posts.remove(postToDelete)
}
model.postToDelete = nil
}
}),
secondaryButton: .cancel() {
model.postToDelete = nil
}
)
}
.alert(isPresented: $model.isPresentingNetworkErrorAlert, content: {
Alert(
title: Text("Connection Error"),
message: Text("There is no internet connection at the moment. Please reconnect or try again later"),
dismissButton: .default(Text("OK"), action: {
model.isPresentingNetworkErrorAlert = false
})
)
})
#if os(iOS)
EmptyView()
.sheet(
isPresented: $model.isPresentingSettingsView,
onDismiss: { model.isPresentingSettingsView = false },
content: {
SettingsView()
.environmentObject(model)
}
)
#endif
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = LocalStorageManager.persistentContainer.viewContext
let model = WriteFreelyModel()
return ContentView()
.environment(\.managedObjectContext, context)
.environmentObject(model)
}
}
diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift
index 9dbcf68..23a4aa5 100644
--- a/Shared/PostCollection/CollectionListView.swift
+++ b/Shared/PostCollection/CollectionListView.swift
@@ -1,52 +1,51 @@
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 {
if model.account.isLoggedIn {
NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: true)) {
Text("All Posts")
}
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)
}
}
}
} else {
NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) {
Text("Drafts")
}
}
}
.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/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift
index 84e774c..8d83713 100644
--- a/Shared/PostEditor/PostEditorModel.swift
+++ b/Shared/PostEditor/PostEditorModel.swift
@@ -1,38 +1,32 @@
-import Foundation
+import SwiftUI
import CoreData
enum PostAppearance: String {
case sans = "OpenSans-Regular"
case mono = "Hack-Regular"
case serif = "Lora-Regular"
}
struct PostEditorModel {
- let lastDraftObjectURLKey = "lastDraftObjectURLKey"
- private(set) var lastDraft: WFAPost?
+ @AppStorage("lastDraftURL") private var lastDraftURL: URL?
- mutating func setLastDraft(_ post: WFAPost) {
- lastDraft = post
- UserDefaults.standard.set(post.objectID.uriRepresentation(), forKey: lastDraftObjectURLKey)
+ func saveLastDraft(_ post: WFAPost) {
+ self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil
}
- mutating func fetchLastDraft() -> WFAPost? {
- let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator
-
- // See if we have a lastDraftObjectURI
- guard let lastDraftObjectURI = UserDefaults.standard.url(forKey: lastDraftObjectURLKey) else { return nil }
+ func clearLastDraft() {
+ self.lastDraftURL = nil
+ }
- // See if we can get an ObjectID from the URI representation
- guard let lastDraftObjectID = coordinator.managedObjectID(forURIRepresentation: lastDraftObjectURI) else {
- return nil
- }
+ func fetchLastDraftFromUserDefaults() -> WFAPost? {
+ guard let postURL = lastDraftURL else { return nil }
- lastDraft = LocalStorageManager.persistentContainer.viewContext.object(with: lastDraftObjectID) as? WFAPost
- return lastDraft
- }
+ let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator
+ guard let postManagedObjectID = coordinator.managedObjectID(forURIRepresentation: postURL) else { return nil }
+ guard let post = LocalStorageManager.persistentContainer.viewContext.object(
+ with: postManagedObjectID
+ ) as? WFAPost else { return nil }
- mutating func clearLastDraft() {
- lastDraft = nil
- UserDefaults.standard.removeObject(forKey: lastDraftObjectURLKey)
+ return post
}
}
diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift
index bb42ba3..6b37511 100644
--- a/Shared/PostList/PostListFilteredView.swift
+++ b/Shared/PostList/PostListFilteredView.swift
@@ -1,101 +1,114 @@
import SwiftUI
struct PostListFilteredView: View {
@EnvironmentObject var model: WriteFreelyModel
-
+ @Binding var postCount: Int
@FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults<WFACollection>
var fetchRequest: FetchRequest<WFAPost>
var showAllPosts: Bool
- init(filter: String?, showAllPosts: Bool) {
+ init(filter: String?, showAllPosts: Bool, postCount: Binding<Int>) {
self.showAllPosts = showAllPosts
if showAllPosts {
fetchRequest = FetchRequest<WFAPost>(
entity: WFAPost.entity(),
sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)]
)
} else {
if let filter = filter {
fetchRequest = FetchRequest<WFAPost>(
entity: WFAPost.entity(),
sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)],
predicate: NSPredicate(format: "collectionAlias == %@", filter)
)
} else {
fetchRequest = FetchRequest<WFAPost>(
entity: WFAPost.entity(),
sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)],
predicate: NSPredicate(format: "collectionAlias == nil")
)
}
}
+ _postCount = postCount
}
var body: some View {
#if os(iOS)
List {
ForEach(fetchRequest.wrappedValue, id: \.self) { post in
NavigationLink(
destination: PostEditorView(post: post),
tag: post,
selection: $model.selectedPost
) {
if showAllPosts {
if let collection = collections.filter { $0.alias == post.collectionAlias }.first {
PostCellView(post: post, collectionName: collection.title)
} else {
let collectionName = model.account.server == "https://write.as" ? "Anonymous" : "Drafts"
PostCellView(post: post, collectionName: collectionName)
}
} else {
PostCellView(post: post)
}
}
.deleteDisabled(post.status != PostStatus.local.rawValue)
}
.onDelete(perform: { indexSet in
for index in indexSet {
let post = fetchRequest.wrappedValue[index]
delete(post)
}
})
}
+ .onAppear(perform: {
+ self.postCount = fetchRequest.wrappedValue.count
+ })
+ .onChange(of: fetchRequest.wrappedValue.count, perform: { value in
+ self.postCount = value
+ })
#else
List {
ForEach(fetchRequest.wrappedValue, id: \.self) { post in
NavigationLink(
destination: PostEditorView(post: post),
tag: post,
selection: $model.selectedPost
) {
PostCellView(post: post)
}
.deleteDisabled(post.status != PostStatus.local.rawValue)
}
.onDelete(perform: { indexSet in
for index in indexSet {
let post = fetchRequest.wrappedValue[index]
delete(post)
}
})
}
+ .onAppear(perform: {
+ self.postCount = fetchRequest.wrappedValue.count
+ })
+ .onChange(of: fetchRequest.wrappedValue.count, perform: { value in
+ self.postCount = value
+ })
.onDeleteCommand(perform: {
guard let selectedPost = model.selectedPost else { return }
if selectedPost.status == PostStatus.local.rawValue {
model.postToDelete = selectedPost
model.isPresentingDeleteAlert = true
}
})
#endif
}
func delete(_ post: WFAPost) {
model.posts.remove(post)
}
}
struct PostListFilteredView_Previews: PreviewProvider {
static var previews: some View {
- return PostListFilteredView(filter: nil, showAllPosts: false)
+ return PostListFilteredView(filter: nil, showAllPosts: false, postCount: .constant(999))
}
}
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..dddca79 100644
--- a/Shared/PostList/PostListView.swift
+++ b/Shared/PostList/PostListView.swift
@@ -1,143 +1,125 @@
import SwiftUI
+import Combine
struct PostListView: View {
@EnvironmentObject var model: WriteFreelyModel
- @Environment(\.managedObjectContext) var moc
+ @Environment(\.managedObjectContext) var managedObjectContext
@State var selectedCollection: WFACollection?
@State var showAllPosts: Bool = false
+ @State private var postCount: Int = 0
var body: some View {
#if os(iOS)
GeometryReader { geometry in
- PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts)
+ PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount)
.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)))
+ Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts")
.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"
+ PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount)
+ .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 }
+ .navigationSubtitle(postCount == 1 ? "\(postCount) post" : "\(postCount) posts")
+ .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 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 36d05f6..a5699f4 100644
--- a/iOS/PostEditor/PostEditorView.swift
+++ b/iOS/PostEditor/PostEditorView.swift
@@ -1,245 +1,238 @@
import SwiftUI
struct PostEditorView: View {
@EnvironmentObject var model: WriteFreelyModel
+ @Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.managedObjectContext) var moc
@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: post.status, perform: { _ in
- if post.status != PostStatus.published.rawValue {
- DispatchQueue.main.async {
- model.editor.setLastDraft(post)
- }
- } else {
- DispatchQueue.main.async {
- model.editor.clearLastDraft()
- }
- }
- })
.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 }
+ if post.status != PostStatus.published.rawValue {
+ self.model.editor.saveLastDraft(post)
+ } else {
+ self.model.editor.clearLastDraft()
+ }
})
.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 ba7cb53..6d2b238 100644
--- a/macOS/PostEditor/PostEditorView.swift
+++ b/macOS/PostEditor/PostEditorView.swift
@@ -1,112 +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
}
})
- .onChange(of: post.status, perform: { _ in
- if post.status != PostStatus.published.rawValue {
- DispatchQueue.main.async {
- model.editor.setLastDraft(post)
- }
- } else {
- DispatchQueue.main.async {
- model.editor.clearLastDraft()
- }
- }
- })
.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
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jun 7, 12:37 AM (12 h, 56 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3259557
Attached To
rWFSUI WriteFreely SwiftUI
Event Timeline
Log In to Comment