Page Menu
Home
Musing Studio
Search
Configure Global Search
Log In
Files
F10455774
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
44 KB
Subscribers
None
View Options
diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift
index 8cb36dd..286f39b 100644
--- a/Shared/Models/WriteFreelyModel.swift
+++ b/Shared/Models/WriteFreelyModel.swift
@@ -1,552 +1,571 @@
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 selectedCollection: WFACollection?
@Published var showAllPosts: Bool = true
@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 reviewURL = URL(string: "https://apps.apple.com/app/id1531530896?action=write-review")!
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")
+ private var postToUpdate: WFAPost?
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) {
+ postToUpdate = nil
+
if !hasNetworkConnection {
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true }
return
}
guard let loggedInClient = client else { return }
// We're starting the network request.
DispatchQueue.main.async {
self.isProcessingRequest = true
}
if post.language == nil {
if let languageCode = Locale.current.languageCode {
post.language = languageCode
post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft
}
}
var wfPost = WFPost(
body: post.body,
title: post.title.isEmpty ? "" : post.title,
appearance: post.appearance,
language: post.language,
rtl: post.rtl,
createdDate: post.createdDate
)
if let existingPostId = post.postId {
// This is an existing post.
+ postToUpdate = post
wfPost.postId = post.postId
- 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 WFError.unauthorized {
DispatchQueue.main.async {
self.loginErrorMessage = "Something went wrong, please try logging in again."
self.isPresentingLoginErrorAlert = true
}
self.logout()
} catch {
print(error)
}
}
func fetchUserPostsHandler(result: Result<[WFPost], Error>) {
// We're done with the network request.
DispatchQueue.main.async {
self.isProcessingRequest = false
}
let request = WFAPost.createFetchRequest()
do {
let locallyCachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
do {
var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue }
let fetchedPosts = try result.get()
for fetchedPost in fetchedPosts {
if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) {
DispatchQueue.main.async {
managedPost.wasDeletedFromServer = false
if let fetchedPostUpdatedDate = fetchedPost.updatedDate,
let localPostUpdatedDate = managedPost.updatedDate {
managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate
} else { print("Error: could not determine which copy of post is newer") }
postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId })
}
} else {
DispatchQueue.main.async {
let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext)
managedPost.postId = fetchedPost.postId
managedPost.slug = fetchedPost.slug
managedPost.appearance = fetchedPost.appearance
managedPost.language = fetchedPost.language
managedPost.rtl = fetchedPost.rtl ?? false
managedPost.createdDate = fetchedPost.createdDate
managedPost.updatedDate = fetchedPost.updatedDate
managedPost.title = fetchedPost.title ?? ""
managedPost.body = fetchedPost.body
managedPost.collectionAlias = fetchedPost.collectionAlias
managedPost.status = PostStatus.published.rawValue
managedPost.wasDeletedFromServer = false
}
}
}
DispatchQueue.main.async {
for post in postsToDelete { post.wasDeletedFromServer = true }
LocalStorageManager().saveContext()
}
} catch {
print(error)
}
} catch WFError.unauthorized {
DispatchQueue.main.async {
self.loginErrorMessage = "Something went wrong, please try logging in again."
self.isPresentingLoginErrorAlert = true
}
self.logout()
} catch {
print("Error: Failed to fetch cached posts")
}
}
func publishHandler(result: Result<WFPost, Error>) {
// We're done with the network request.
DispatchQueue.main.async {
self.isProcessingRequest = false
}
// ⚠️ NOTE:
// The API does not return a collection alias, so we take care not to overwrite the
// cached post's collection alias with the 'nil' value from the fetched post.
// See: https://github.com/writeas/writefreely-swift/issues/20
do {
let fetchedPost = try result.get()
- 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
+ // If this is an updated post, check it against postToUpdate.
+ if let updatingPost = self.postToUpdate {
+ updatingPost.appearance = fetchedPost.appearance
+ updatingPost.body = fetchedPost.body
+ updatingPost.createdDate = fetchedPost.createdDate
+ updatingPost.language = fetchedPost.language
+ updatingPost.postId = fetchedPost.postId
+ updatingPost.rtl = fetchedPost.rtl ?? false
+ updatingPost.slug = fetchedPost.slug
+ updatingPost.status = PostStatus.published.rawValue
+ updatingPost.title = fetchedPost.title ?? ""
+ updatingPost.updatedDate = fetchedPost.updatedDate
DispatchQueue.main.async {
LocalStorageManager().saveContext()
}
- } catch {
- print("Error: Failed to fetch cached posts")
+ } else {
+ // Otherwise if it's a newly-published post, find it in the local store.
+ let request = WFAPost.createFetchRequest()
+ let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body)
+ if let fetchedPostTitle = fetchedPost.title {
+ let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle)
+ request.predicate = NSCompoundPredicate(
+ andPredicateWithSubpredicates: [
+ matchTitlePredicate,
+ matchBodyPredicate
+ ]
+ )
+ } else {
+ request.predicate = matchBodyPredicate
+ }
+ do {
+ let cachedPostsResults = try LocalStorageManager.persistentContainer.viewContext.fetch(request)
+ guard let cachedPost = cachedPostsResults.first else { return }
+ cachedPost.appearance = fetchedPost.appearance
+ cachedPost.body = fetchedPost.body
+ cachedPost.createdDate = fetchedPost.createdDate
+ cachedPost.language = fetchedPost.language
+ cachedPost.postId = fetchedPost.postId
+ cachedPost.rtl = fetchedPost.rtl ?? false
+ cachedPost.slug = fetchedPost.slug
+ cachedPost.status = PostStatus.published.rawValue
+ cachedPost.title = fetchedPost.title ?? ""
+ cachedPost.updatedDate = fetchedPost.updatedDate
+ DispatchQueue.main.async {
+ LocalStorageManager().saveContext()
+ }
+ } catch {
+ print("Error: Failed to fetch cached posts")
+ }
}
} catch {
print(error)
}
}
func updateFromServerHandler(result: Result<WFPost, Error>) {
// We're done with the network request.
DispatchQueue.main.async {
self.isProcessingRequest = false
}
// ⚠️ NOTE:
// The API does not return a collection alias, so we take care not to overwrite the
// cached post's collection alias with the 'nil' value from the fetched post.
// See: https://github.com/writeas/writefreely-swift/issues/20
do {
let fetchedPost = try result.get()
guard let cachedPost = self.selectedPost else { return }
cachedPost.appearance = fetchedPost.appearance
cachedPost.body = fetchedPost.body
cachedPost.createdDate = fetchedPost.createdDate
cachedPost.language = fetchedPost.language
cachedPost.postId = fetchedPost.postId
cachedPost.rtl = fetchedPost.rtl ?? false
cachedPost.slug = fetchedPost.slug
cachedPost.status = PostStatus.published.rawValue
cachedPost.title = fetchedPost.title ?? ""
cachedPost.updatedDate = fetchedPost.updatedDate
cachedPost.hasNewerRemoteCopy = false
DispatchQueue.main.async {
LocalStorageManager().saveContext()
}
} catch {
print(error)
}
}
func movePostHandler(result: Result<Bool, Error>) {
// We're done with the network request.
DispatchQueue.main.async {
self.isProcessingRequest = false
}
do {
let succeeded = try result.get()
if succeeded {
if let post = selectedPost {
updateFromServer(post: post)
} else {
return
}
}
} catch {
DispatchQueue.main.async {
LocalStorageManager.persistentContainer.viewContext.rollback()
}
print(error)
}
}
}
private extension WriteFreelyModel {
// MARK: - Keychain Helpers
func saveTokenToKeychain(_ token: String, username: String?, server: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccount as String: username ?? "anonymous",
kSecAttrService as String: server
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecDuplicateItem || status == errSecSuccess else {
fatalError("Error storing in Keychain with OSStatus: \(status)")
}
}
func purgeTokenFromKeychain(username: String?, server: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: username ?? "anonymous",
kSecAttrService as String: server
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
fatalError("Error deleting from Keychain with OSStatus: \(status)")
}
}
func fetchTokenFromKeychain(username: String?, server: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: username ?? "anonymous",
kSecAttrService as String: server,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
var secItem: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &secItem)
guard status != errSecItemNotFound else {
return nil
}
guard status == errSecSuccess else {
fatalError("Error fetching from Keychain with OSStatus: \(status)")
}
guard let existingSecItem = secItem as? [String: Any],
let tokenData = existingSecItem[kSecValueData as String] as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
}
diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift
index 5f0bdc6..149f37b 100644
--- a/Shared/PostList/PostListView.swift
+++ b/Shared/PostList/PostListView.swift
@@ -1,148 +1,150 @@
import SwiftUI
import Combine
struct PostListView: View {
@EnvironmentObject var model: WriteFreelyModel
@Environment(\.managedObjectContext) var managedObjectContext
@State private var postCount: Int = 0
#if os(iOS)
private var frameHeight: CGFloat {
var height: CGFloat = 50
let bottom = UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0
height += bottom
return height
}
#endif
var body: some View {
#if os(iOS)
ZStack(alignment: .bottom) {
PostListFilteredView(
collection: model.selectedCollection,
showAllPosts: model.showAllPosts,
postCount: $postCount
)
.navigationTitle(
model.showAllPosts ? "All Posts" : model.selectedCollection?.title ?? (
model.account.server == "https://write.as" ? "Anonymous" : "Drafts"
)
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
// We have to add a Spacer as a sibling view to the Button in some kind of Stack, so that any
// a11y modifiers are applied as expected: bug report filed as FB8956392.
ZStack {
Spacer()
Button(action: {
let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font)
withAnimation {
self.model.showAllPosts = false
self.model.selectedCollection = nil
self.model.selectedPost = managedPost
}
}, label: {
ZStack {
Image("does.not.exist")
.accessibilityHidden(true)
Image(systemName: "square.and.pencil")
.accessibilityHidden(true)
.imageScale(.large) // These modifiers compensate for the resizing
.padding(.vertical, 12) // done to the Image (and the button tap target)
.padding(.leading, 12) // by the SwiftUI layout system from adding a
.padding(.trailing, 8) // Spacer in this ZStack (FB8956392).
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.accessibilityLabel(Text("Compose"))
.accessibilityHint(Text("Compose a new local draft"))
}
}
}
VStack {
HStack(spacing: 0) {
Button(action: {
model.isPresentingSettingsView = true
}, label: {
Image(systemName: "gear")
.padding(.vertical, 4)
.padding(.horizontal, 8)
})
.accessibilityLabel(Text("Settings"))
.accessibilityHint(Text("Open the Settings sheet"))
Spacer()
Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts")
.foregroundColor(.secondary)
Spacer()
if model.isProcessingRequest {
ProgressView()
.padding(.vertical, 4)
.padding(.horizontal, 8)
} else {
Button(action: {
DispatchQueue.main.async {
model.fetchUserCollections()
model.fetchUserPosts()
}
}, label: {
Image(systemName: "arrow.clockwise")
.padding(.vertical, 4)
.padding(.horizontal, 8)
})
.accessibilityLabel(Text("Refresh Posts"))
.accessibilityHint(Text("Fetch changes from the server"))
.disabled(!model.account.isLoggedIn)
}
}
.padding(.top, 8)
.padding(.horizontal, 8)
Spacer()
}
.frame(height: frameHeight)
.background(Color(UIColor.systemGray5))
.overlay(Divider(), alignment: .top)
}
.ignoresSafeArea()
#else //if os(macOS)
PostListFilteredView(
collection: model.selectedCollection,
showAllPosts: model.showAllPosts,
postCount: $postCount
)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
- ActivePostToolbarView()
- .alert(isPresented: $model.isPresentingNetworkErrorAlert, content: {
- Alert(
- title: Text("Connection Error"),
- message: Text("""
+ if model.selectedPost != nil {
+ ActivePostToolbarView(activePost: model.selectedPost!)
+ .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
- })
- )
- })
+ dismissButton: .default(Text("OK"), action: {
+ model.isPresentingNetworkErrorAlert = false
+ })
+ )
+ })
+ }
}
}
.navigationTitle(
model.showAllPosts ? "All Posts" : model.selectedCollection?.title ?? (
model.account.server == "https://write.as" ? "Anonymous" : "Drafts"
)
)
#endif
}
}
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/macOS/Navigation/ActivePostToolbarView.swift b/macOS/Navigation/ActivePostToolbarView.swift
index bd18ff9..f95d123 100644
--- a/macOS/Navigation/ActivePostToolbarView.swift
+++ b/macOS/Navigation/ActivePostToolbarView.swift
@@ -1,130 +1,130 @@
import SwiftUI
struct ActivePostToolbarView: View {
@EnvironmentObject var model: WriteFreelyModel
+ @ObservedObject var activePost: WFAPost
@State private var isPresentingSharingServicePicker: 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 {
- if let activePost = model.selectedPost {
- HStack {
- if model.account.isLoggedIn &&
- activePost.status != PostStatus.local.rawValue &&
- !(activePost.wasDeletedFromServer || activePost.hasNewerRemoteCopy) {
- Section(header: Text("Move To:")) {
- Picker(selection: $selectedCollection, label: Text("Move To…"), content: {
- Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
- .tag(nil as WFACollection?)
- Divider()
- ForEach(collections) { collection in
- Text("\(collection.title)").tag(collection as WFACollection?)
- }
- })
- }
- }
- PostEditorStatusToolbarView(post: activePost)
- .frame(minWidth: 50, alignment: .center)
- .layoutPriority(1)
- .padding(.horizontal)
- if activePost.status == PostStatus.local.rawValue {
- Menu(content: {
- Label("Publish To:", systemImage: "paperplane")
+ HStack {
+ if model.account.isLoggedIn &&
+ activePost.status != PostStatus.local.rawValue &&
+ !(activePost.wasDeletedFromServer || activePost.hasNewerRemoteCopy) {
+ Section(header: Text("Move To:")) {
+ Picker(selection: $selectedCollection, label: Text("Move To…"), content: {
+ Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
+ .tag(nil as WFACollection?)
Divider()
+ ForEach(collections) { collection in
+ Text("\(collection.title)").tag(collection as WFACollection?)
+ }
+ })
+ }
+ }
+ PostEditorStatusToolbarView(post: activePost)
+ .frame(minWidth: 50, alignment: .center)
+ .layoutPriority(1)
+ .padding(.horizontal)
+ if activePost.status == PostStatus.local.rawValue {
+ Menu(content: {
+ Label("Publish To:", systemImage: "paperplane")
+ Divider()
+ Button(action: {
+ if model.account.isLoggedIn {
+ withAnimation {
+ activePost.collectionAlias = nil
+ publishPost(activePost)
+ }
+ } else {
+ openSettingsWindow()
+ }
+ }, label: {
+ Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
+ })
+ ForEach(collections) { collection in
Button(action: {
if model.account.isLoggedIn {
withAnimation {
- activePost.collectionAlias = nil
+ activePost.collectionAlias = collection.alias
publishPost(activePost)
}
} else {
openSettingsWindow()
}
}, label: {
- Text("\(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
+ Text("\(collection.title)")
})
- ForEach(collections) { collection in
- Button(action: {
- if model.account.isLoggedIn {
- withAnimation {
- activePost.collectionAlias = collection.alias
- publishPost(activePost)
- }
- } else {
- openSettingsWindow()
- }
- }, label: {
- Text("\(collection.title)")
- })
- }
- }, label: {
- Label("Publish…", systemImage: "paperplane")
- })
- .disabled(activePost.body.isEmpty)
- .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length
- } else {
- HStack(spacing: 4) {
- Button(
- action: {
- self.isPresentingSharingServicePicker = true
- },
- label: { Image(systemName: "square.and.arrow.up") }
+ }
+ }, label: {
+ Label("Publish…", systemImage: "paperplane")
+ })
+ .disabled(model.selectedPost?.body.isEmpty ?? true)
+ .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length
+ } else {
+ HStack(spacing: 4) {
+ Button(
+ action: {
+ self.isPresentingSharingServicePicker = true
+ },
+ label: { Image(systemName: "square.and.arrow.up") }
+ )
+ .disabled(activePost.status == PostStatus.local.rawValue)
+ .help("Copy the post's URL to your Mac's pasteboard.")
+ .popover(isPresented: $isPresentingSharingServicePicker) {
+ PostEditorSharingPicker(
+ isPresented: $isPresentingSharingServicePicker,
+ sharingItems: createPostUrl()
)
- .disabled(activePost.status == PostStatus.local.rawValue)
- .help("Copy the post's URL to your Mac's pasteboard.")
- .popover(isPresented: $isPresentingSharingServicePicker) {
- PostEditorSharingPicker(
- isPresented: $isPresentingSharingServicePicker,
- sharingItems: createPostUrl()
- )
- .frame(width: .zero, height: .zero)
- }
- Button(action: { publishPost(activePost) }, label: { Image(systemName: "paperplane") })
- .disabled(activePost.body.isEmpty || activePost.status == PostStatus.published.rawValue)
- .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length
+ .frame(width: .zero, height: .zero)
}
+ Button(action: { publishPost(activePost) }, label: { Image(systemName: "paperplane") })
+ .disabled(activePost.body.isEmpty || activePost.status == PostStatus.published.rawValue)
+ .help("Publish the post to the web.\(model.account.isLoggedIn ? "" : " You must be logged in to do this.")") // swiftlint:disable:this line_length
}
}
- .onAppear(perform: {
- self.selectedCollection = collections.first { $0.alias == activePost.collectionAlias }
- })
- .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
- if activePost.collectionAlias == newCollection?.alias {
- return
- } else {
- withAnimation {
- activePost.collectionAlias = newCollection?.alias
- model.move(post: activePost, from: selectedCollection, to: newCollection)
- }
- }
- })
- } else {
- EmptyView()
}
+ .onAppear(perform: {
+ self.selectedCollection = collections.first { $0.alias == activePost.collectionAlias }
+ })
+ .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
+ if activePost.collectionAlias == newCollection?.alias {
+ return
+ } else {
+ withAnimation {
+ activePost.collectionAlias = newCollection?.alias
+ model.move(post: activePost, from: selectedCollection, to: newCollection)
+ }
+ }
+ })
}
private func createPostUrl() -> [Any] {
guard let postId = model.selectedPost?.postId else { return [] }
guard let urlString = model.selectedPost?.slug != nil ?
"\(model.account.server)/\((model.selectedPost?.collectionAlias)!)/\((model.selectedPost?.slug)!)" :
"\(model.account.server)/\((postId))" else { return [] }
guard let data = URL(string: urlString) else { return [] }
return [data as NSURL]
}
private func publishPost(_ post: WFAPost) {
+ if post != model.selectedPost {
+ return
+ }
DispatchQueue.main.async {
LocalStorageManager().saveContext()
model.publish(post: post)
}
}
private func openSettingsWindow() {
guard let menuItem = NSApplication.shared.mainMenu?.item(at: 0)?.submenu?.item(at: 2) else { return }
NSApplication.shared.sendAction(menuItem.action!, to: menuItem.target, from: nil)
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Jan 31, 6:17 PM (19 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3145973
Attached To
rWFSUI WriteFreely SwiftUI
Event Timeline
Log In to Comment