diff --git a/ActionExtension-iOS/ContentView.swift b/ActionExtension-iOS/ContentView.swift index 0d38361..3e7b0d6 100644 --- a/ActionExtension-iOS/ContentView.swift +++ b/ActionExtension-iOS/ContentView.swift @@ -1,199 +1,198 @@ import SwiftUI import MobileCoreServices import UniformTypeIdentifiers import WriteFreely enum WFActionExtensionError: Error { case userCancelledRequest case couldNotParseInputItems } struct ContentView: View { @Environment(\.extensionContext) private var extensionContext: NSExtensionContext! @Environment(\.managedObjectContext) private var managedObjectContext @AppStorage(WFDefaults.defaultFontIntegerKey, store: UserDefaults.shared) var fontIndex: Int = 0 @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults @State private var draftTitle: String = "" @State private var draftText: String = "" @State private var isShowingAlert: Bool = false @State private var selectedBlog: WFACollection? private var draftsCollectionName: String { guard UserDefaults.shared.string(forKey: WFDefaults.serverStringKey) == "https://write.as" else { return "Drafts" } return "Anonymous" } private var controls: some View { HStack { Group { Button( action: { extensionContext.cancelRequest(withError: WFActionExtensionError.userCancelledRequest) }, label: { Image(systemName: "xmark.circle").imageScale(.large) } ) .accessibilityLabel(Text("Cancel")) Spacer() Button( action: { savePostToCollection(collection: selectedBlog, title: draftTitle, body: draftText) extensionContext.completeRequest(returningItems: nil, completionHandler: nil) }, label: { Image(systemName: "square.and.arrow.down").imageScale(.large) } ) .accessibilityLabel(Text("Create new draft")) } .padding() } } var body: some View { VStack { controls Form { Section(header: Text("Title")) { switch fontIndex { case 1: TextField("Draft Title", text: $draftTitle).font(.custom("OpenSans-Regular", size: 26)) case 2: TextField("Draft Title", text: $draftTitle).font(.custom("Hack-Regular", size: 26)) default: TextField("Draft Title", text: $draftTitle).font(.custom("Lora", size: 26)) } } Section(header: Text("Content")) { switch fontIndex { case 1: TextEditor(text: $draftText).font(.custom("OpenSans-Regular", size: 17)) case 2: TextEditor(text: $draftText).font(.custom("Hack-Regular", size: 17)) default: TextEditor(text: $draftText).font(.custom("Lora", size: 17)) } } Section(header: Text("Save To")) { Button(action: { self.selectedBlog = nil }, label: { HStack { Text(draftsCollectionName) .foregroundColor(selectedBlog == nil ? .primary : .secondary) Spacer() if selectedBlog == nil { Image(systemName: "checkmark") } } }) ForEach(collections, id: \.self) { collection in Button(action: { self.selectedBlog = collection }, label: { HStack { Text(collection.title) .foregroundColor(selectedBlog == collection ? .primary : .secondary) Spacer() if selectedBlog == collection { Image(systemName: "checkmark") } } }) } } } .padding(.bottom, 24) } .alert(isPresented: $isShowingAlert, content: { Alert( title: Text("Something Went Wrong"), message: Text("WriteFreely can't create a draft with the data received."), dismissButton: .default(Text("OK"), action: { extensionContext.cancelRequest(withError: WFActionExtensionError.couldNotParseInputItems) })) }) .onAppear { do { try getPageDataFromExtensionContext() } catch { self.isShowingAlert = true } } } private func savePostToCollection(collection: WFACollection?, title: String, body: String) { let post = WFAPost(context: managedObjectContext) post.createdDate = Date() post.title = title post.body = body post.status = PostStatus.local.rawValue post.collectionAlias = collection?.alias switch fontIndex { case 1: post.appearance = "sans" case 2: post.appearance = "wrap" default: post.appearance = "serif" } if let languageCode = Locale.current.languageCode { post.language = languageCode post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } LocalStorageManager.standard.saveContext() } private func getPageDataFromExtensionContext() throws { if let inputItem = extensionContext.inputItems.first as? NSExtensionItem { if let itemProvider = inputItem.attachments?.first { let typeIdentifier: String if #available(iOS 15, *) { typeIdentifier = UTType.propertyList.identifier } else { typeIdentifier = kUTTypePropertyList as String } itemProvider.loadItem(forTypeIdentifier: typeIdentifier) { (dict, error) in - if let error = error { - print("⚠️", error) + if error != nil { self.isShowingAlert = true } guard let itemDict = dict as? NSDictionary else { return } guard let jsValues = itemDict[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return } let pageTitle = jsValues["title"] as? String ?? "" let pageURL = jsValues["URL"] as? String ?? "" let pageSelectedText = jsValues["selection"] as? String ?? "" if pageSelectedText.isEmpty { // If there's no selected text, create a Markdown link to the webpage. self.draftText = "[\(pageTitle)](\(pageURL))" } else { // If there is selected text, create a Markdown blockquote with the selection // and add a Markdown link to the webpage. self.draftText = """ > \(pageSelectedText) Via: [\(pageTitle)](\(pageURL)) """ } } } else { throw WFActionExtensionError.couldNotParseInputItems } } else { throw WFActionExtensionError.couldNotParseInputItems } } } diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 617385f..ba778d0 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,102 +1,103 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel final class WriteFreelyModel: ObservableObject { // MARK: - Models @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() // MARK: - Error handling @Published var hasError: Bool = false var currentError: Error? { didSet { + // TODO: Remove print statements for debugging before closing #204. #if DEBUG print("⚠️ currentError -> didSet \(currentError?.localizedDescription ?? "nil")") print(" > hasError was: \(self.hasError)") #endif DispatchQueue.main.async { #if DEBUG print(" > self.currentError != nil: \(self.currentError != nil)") #endif self.hasError = self.currentError != nil #if DEBUG print(" > hasError is now: \(self.hasError)") #endif } } } // MARK: - State @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 postToDelete: WFAPost? #if os(iOS) @Published var isPresentingSettingsView: Bool = false #endif static var shared = WriteFreelyModel() // 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 internal var client: WFClient? private let defaults = UserDefaults.shared private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") internal var postToUpdate: WFAPost? init() { DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: WFDefaults.colorSchemeIntegerKey) self.preferences.font = self.defaults.integer(forKey: WFDefaults.defaultFontIntegerKey) self.account.restoreState() if self.account.isLoggedIn { guard let serverURL = URL(string: self.account.server) else { self.currentError = AccountError.invalidServerURL return } do { guard let token = try self.fetchTokenFromKeychain( username: self.account.username, server: self.account.server ) else { self.currentError = KeychainError.couldNotFetchAccessToken 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() } catch { self.currentError = KeychainError.couldNotFetchAccessToken return } } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 7a78ad6..4fed230 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,268 +1,275 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @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 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 ) + .withErrorHandling() } .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") }) .accessibilityHint(Text("Choose the blog you want to publish this post to")) .disabled(post.body.count == 0) } 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") }) .accessibilityHint(Text("Open the system share sheet to share a link to this post")) .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: { ZStack { Image("does.not.exist") .accessibilityHidden(true) Image(systemName: "ellipsis.circle") .imageScale(.large) .accessibilityHidden(true) } }) .accessibilityLabel(Text("Menu")) .accessibilityHint(Text("Opens a context menu to publish, share, or move the post")) .onTapGesture { hideKeyboard() } .disabled(post.body.count == 0) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .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.standard.saveContext() } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { self.model.editor.saveLastDraft(post) } } else { self.model.editor.clearLastDraft() } }) + .onChange(of: model.hasError) { value in + if value { + if let error = model.currentError { + self.errorHandling.handle(error: error) + } else { + self.errorHandling.handle(error: AppError.genericError()) + } + model.hasError = false + } + } .onDisappear(perform: { self.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.standard.saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() 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 baseURL/postSlug. let urls = collections.filter { $0.alias == postCollectionAlias } let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/" urlString = "\(baseURL)\(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.standard.container.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.standard.container.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) } }