diff --git a/Shared/Account/AccountModel.swift b/Shared/Account/AccountModel.swift index 4ce81b2..25683a5 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -1,66 +1,62 @@ -import Foundation +import SwiftUI import WriteFreely enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound } extension AccountError: LocalizedError { public var errorDescription: String? { switch self { case .serverNotFound: return NSLocalizedString( "The server could not be found. Please check the information you've entered and try again.", comment: "" ) case .invalidPassword: return NSLocalizedString( "Invalid password. Please check that you've entered your password correctly and try logging in again.", comment: "" ) case .usernameNotFound: return NSLocalizedString( "Username not found. Did you use your email address by mistake?", comment: "" ) } } } struct AccountModel { + @AppStorage("isLoggedIn") var isLoggedIn: Bool = false private let defaults = UserDefaults.standard - let isLoggedInFlag = "isLoggedInFlag" let usernameStringKey = "usernameStringKey" let serverStringKey = "serverStringKey" var server: String = "" var username: String = "" private(set) var user: WFUser? - private(set) var isLoggedIn: Bool = false mutating func login(_ user: WFUser) { self.user = user self.username = user.username ?? "" self.isLoggedIn = true - defaults.set(true, forKey: isLoggedInFlag) defaults.set(user.username, forKey: usernameStringKey) defaults.set(server, forKey: serverStringKey) } mutating func logout() { self.user = nil self.isLoggedIn = false - defaults.set(false, forKey: isLoggedInFlag) defaults.removeObject(forKey: usernameStringKey) defaults.removeObject(forKey: serverStringKey) } mutating func restoreState() { - isLoggedIn = defaults.bool(forKey: isLoggedInFlag) server = defaults.string(forKey: serverStringKey) ?? "" username = defaults.string(forKey: usernameStringKey) ?? "" } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 6831b7c..ce8f253 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,67 +1,116 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { + #if os(macOS) SidebarView() + .toolbar { + Button( + action: { + NSApp.keyWindow?.contentViewController?.tryToPerform( + #selector(NSSplitViewController.toggleSidebar(_:)), with: nil + ) + }, + label: { Image(systemName: "sidebar.left") } + ) + Spacer() + Button(action: { + withAnimation { + self.model.selectedPost = nil + } + let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + 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 + } + withAnimation { + DispatchQueue.main.async { + self.model.selectedPost = managedPost + } + } + }, label: { Image(systemName: "square.and.pencil") }) + } + #else + SidebarView() + #endif + #if os(macOS) + PostListView(selectedCollection: nil, showAllPosts: model.account.isLoggedIn) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + if let selectedPost = model.selectedPost { + ActivePostToolbarView(activePost: 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 + }) + ) + }) + } + } + } + #else PostListView(selectedCollection: nil, showAllPosts: model.account.isLoggedIn) + #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } .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 - 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) } ) + .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 + }) + ) + }) #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/PostEditor/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift index 580e051..7d8cac4 100644 --- a/Shared/PostEditor/PostEditorStatusToolbarView.swift +++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift @@ -1,88 +1,98 @@ import SwiftUI struct PostEditorStatusToolbarView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var body: some View { if post.hasNewerRemoteCopy { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { + HStack { + Text("⚠️ Newer copy on server. Replace local copy?") + .font(.callout) + .foregroundColor(.secondary) + Button(action: { + model.updateFromServer(post: post) + }, label: { + Image(systemName: "square.and.arrow.down") + }) + } + .padding(.horizontal) + .background(Color.primary.opacity(0.1)) + .clipShape(Capsule()) + .padding(.trailing) PostStatusBadgeView(post: post) - .padding(.trailing) - Text("⚠️ Newer copy on server. Replace local copy?") - .font(.callout) - .foregroundColor(.secondary) - Button(action: { - model.updateFromServer(post: post) - }, label: { - Image(systemName: "square.and.arrow.down") - }) } #endif } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { + HStack { + Text("⚠️ Post deleted from server. Delete local copy?") + .font(.callout) + .foregroundColor(.secondary) + Button(action: { + model.selectedPost = nil + DispatchQueue.main.async { + model.posts.remove(post) + } + }, label: { + Image(systemName: "trash") + }) + } + .padding(.horizontal) + .background(Color.primary.opacity(0.1)) + .clipShape(Capsule()) + .padding(.trailing) PostStatusBadgeView(post: post) - .padding(.trailing) - Text("⚠️ Post deleted from server. Delete local copy?") - .font(.callout) - .foregroundColor(.secondary) - Button(action: { - model.selectedPost = nil - DispatchQueue.main.async { - model.posts.remove(post) - } - }, label: { - Image(systemName: "trash") - }) } #endif } else { PostStatusBadgeView(post: post) } } } struct PESTView_StandardPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let testPost = WFAPost(context: context) testPost.status = PostStatus.published.rawValue return PostEditorStatusToolbarView(post: testPost) .environmentObject(model) } } struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let updatedPost = WFAPost(context: context) updatedPost.status = PostStatus.published.rawValue updatedPost.hasNewerRemoteCopy = true return PostEditorStatusToolbarView(post: updatedPost) .environmentObject(model) } } struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let deletedPost = WFAPost(context: context) deletedPost.status = PostStatus.published.rawValue deletedPost.wasDeletedFromServer = true return PostEditorStatusToolbarView(post: deletedPost) .environmentObject(model) } } diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift index 6b37511..cc65779 100644 --- a/Shared/PostList/PostListFilteredView.swift +++ b/Shared/PostList/PostListFilteredView.swift @@ -1,114 +1,139 @@ import SwiftUI struct PostListFilteredView: View { @EnvironmentObject var model: WriteFreelyModel @Binding var postCount: Int @FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults var fetchRequest: FetchRequest var showAllPosts: Bool init(filter: String?, showAllPosts: Bool, postCount: Binding) { self.showAllPosts = showAllPosts if showAllPosts { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)] ) } else { if let filter = filter { fetchRequest = FetchRequest( entity: WFAPost.entity(), sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], predicate: NSPredicate(format: "collectionAlias == %@", filter) ) } else { fetchRequest = FetchRequest( 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) } }) } + .alert(isPresented: $model.isPresentingDeleteAlert) { + Alert( + title: Text("Delete Post?"), + message: Text("This action cannot be undone."), + primaryButton: .cancel() { + model.postToDelete = nil + }, + secondaryButton: .destructive(Text("Delete"), action: { + if let postToDelete = model.postToDelete { + model.selectedPost = nil + DispatchQueue.main.async { + model.editor.clearLastDraft() + model.posts.remove(postToDelete) + } + model.postToDelete = nil + } + }) + ) + } .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) + DispatchQueue.main.async { + if post == model.selectedPost { + model.selectedPost = nil + model.editor.clearLastDraft() + } + model.posts.remove(post) + } } } struct PostListFilteredView_Previews: PreviewProvider { static var previews: some View { return PostListFilteredView(filter: nil, showAllPosts: false, postCount: .constant(999)) } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 98e158b..c7ada24 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -1,124 +1,126 @@ import SwiftUI import CoreData class PostListModel: ObservableObject { func remove(_ post: WFAPost) { - LocalStorageManager.persistentContainer.viewContext.delete(post) - LocalStorageManager().saveContext() + withAnimation { + LocalStorageManager.persistentContainer.viewContext.delete(post) + LocalStorageManager().saveContext() + } } func purgePublishedPosts() { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") fetchRequest.predicate = NSPredicate(format: "status != %i", 0) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try LocalStorageManager.persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { print("Error: Failed to purge cached posts.") } } func getBodyPreview(of post: WFAPost) -> String { var elidedPostBody: String = "" // Strip any markdown from the post body. let strippedPostBody = stripMarkdown(from: post.body) // Extract lede from post. elidedPostBody = extractLede(from: strippedPostBody) return elidedPostBody } } private extension PostListModel { func stripMarkdown(from string: String) -> String { var strippedString = string strippedString = stripHeadingOctothorpes(from: strippedString) strippedString = stripImages(from: strippedString, keepAltText: true) return strippedString } func stripHeadingOctothorpes(from string: String) -> String { let newLines = CharacterSet.newlines var processedComponents: [String] = [] let components = string.components(separatedBy: newLines) for component in components { if component.isEmpty { continue } var newString = component while newString.first == "#" { newString.removeFirst() } if newString.hasPrefix(" ") { newString.removeFirst() } processedComponents.append(newString) } let headinglessString = processedComponents.joined(separator: "\n\n") return headinglessString } func stripImages(from string: String, keepAltText: Bool = false) -> String { let pattern = #"!\[[\"]?(.*?)[\"|]?\]\(.*?\)"# var processedComponents: [String] = [] let components = string.components(separatedBy: .newlines) for component in components { if component.isEmpty { continue } var processedString: String = component if keepAltText { let regex = try? NSRegularExpression(pattern: pattern, options: []) if let matches = regex?.matches( in: component, options: [], range: NSRange(location: 0, length: component.utf16.count) ) { for match in matches { if let range = Range(match.range(at: 1), in: component) { processedString = "\(component[range])" } } } } else { let range = component.startIndex.. String { let truncatedString = string.prefix(80) let terminatingPunctuation = ".。?" let terminatingCharacters = CharacterSet(charactersIn: terminatingPunctuation).union(.newlines) var lede: String = "" let sentences = truncatedString.components(separatedBy: terminatingCharacters) if let firstSentence = (sentences.filter { !$0.isEmpty }).first { if truncatedString.count > firstSentence.count { if terminatingPunctuation.contains(truncatedString[firstSentence.endIndex]) { lede = String(truncatedString[...firstSentence.endIndex]) } else { lede = firstSentence } } else if truncatedString.count == firstSentence.count { if string.count > 80 { if let endOfStringIndex = truncatedString.lastIndex(of: " ") { lede = truncatedString[.. SchemeUserState WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ orderHint - 0 + 1 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 1 + 0 diff --git a/macOS/Navigation/ActivePostToolbarView.swift b/macOS/Navigation/ActivePostToolbarView.swift new file mode 100644 index 0000000..68b9d93 --- /dev/null +++ b/macOS/Navigation/ActivePostToolbarView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct ActivePostToolbarView: View { + @EnvironmentObject var model: WriteFreelyModel + @ObservedObject var activePost: WFAPost + + var body: some View { + HStack(spacing: 16) { + PostEditorStatusToolbarView(post: activePost) + HStack(spacing: 4) { + Button(action: {}, label: { Image(systemName: "square.and.arrow.up") }) + .disabled(activePost.status == PostStatus.local.rawValue) + Button(action: { publishPost(activePost) }, label: { Image(systemName: "paperplane") }) + .disabled(activePost.body.isEmpty || activePost.status == PostStatus.published.rawValue) + } + } + } + + private func publishPost(_ post: WFAPost) { + DispatchQueue.main.async { + LocalStorageManager().saveContext() + model.publish(post: post) + } + } +} diff --git a/macOS/PostEditor/PostEditorView.swift b/macOS/PostEditor/PostEditorView.swift index a026563..4b7ab68 100644 --- a/macOS/PostEditor/PostEditorView.swift +++ b/macOS/PostEditor/PostEditorView.swift @@ -1,98 +1,92 @@ 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 updatingFromServer: Bool = false var body: some View { PostTextEditingView( post: post, updatingFromServer: $updatingFromServer ) .padding() .background(Color(NSColor.controlBackgroundColor)) - .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) + .onAppear(perform: { + if post.status != PostStatus.published.rawValue { + DispatchQueue.main.async { + self.model.editor.saveLastDraft(post) + } + } else { + self.model.editor.clearLastDraft() } - } + }) .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { self.updatingFromServer = true } }) + .onChange(of: post.status, perform: { value in + if value != PostStatus.published.rawValue { + self.model.editor.saveLastDraft(post) + } else { + self.model.editor.clearLastDraft() + } + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + }) .onDisappear(perform: { + DispatchQueue.main.async { + model.editor.clearLastDraft() + } 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) } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager().saveContext() } } }) } - - private func publishPost() { - DispatchQueue.main.async { - LocalStorageManager().saveContext() - 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) } }