Source code for the WriteFreely SwiftUI app for iOS, iPadOS, and macOS
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

276 lines
11 KiB

  1. import SwiftUI
  2. struct PostEditorView: View {
  3. @EnvironmentObject var model: WriteFreelyModel
  4. @EnvironmentObject var errorHandling: ErrorHandling
  5. @Environment(\.horizontalSizeClass) var horizontalSizeClass
  6. @Environment(\.managedObjectContext) var moc
  7. @Environment(\.presentationMode) var presentationMode
  8. @ObservedObject var post: WFAPost
  9. @State private var updatingTitleFromServer: Bool = false
  10. @State private var updatingBodyFromServer: Bool = false
  11. @State private var selectedCollection: WFACollection?
  12. @FetchRequest(
  13. entity: WFACollection.entity(),
  14. sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)]
  15. ) var collections: FetchedResults<WFACollection>
  16. var body: some View {
  17. VStack {
  18. if post.hasNewerRemoteCopy {
  19. RemoteChangePromptView(
  20. remoteChangeType: .remoteCopyUpdated,
  21. buttonHandler: { model.updateFromServer(post: post) }
  22. )
  23. } else if post.wasDeletedFromServer {
  24. RemoteChangePromptView(
  25. remoteChangeType: .remoteCopyDeleted,
  26. buttonHandler: {
  27. self.presentationMode.wrappedValue.dismiss()
  28. DispatchQueue.main.async {
  29. model.posts.remove(post)
  30. }
  31. }
  32. )
  33. }
  34. PostTextEditingView(
  35. post: _post,
  36. updatingTitleFromServer: $updatingTitleFromServer,
  37. updatingBodyFromServer: $updatingBodyFromServer
  38. )
  39. .withErrorHandling()
  40. }
  41. .navigationBarTitleDisplayMode(.inline)
  42. .padding()
  43. .toolbar {
  44. ToolbarItem(placement: .principal) {
  45. PostEditorStatusToolbarView(post: post)
  46. }
  47. ToolbarItem(placement: .primaryAction) {
  48. if model.isProcessingRequest {
  49. ProgressView()
  50. } else {
  51. Menu(content: {
  52. if post.status == PostStatus.local.rawValue {
  53. Menu(content: {
  54. Label("Publish to…", systemImage: "paperplane")
  55. Button(action: {
  56. if model.account.isLoggedIn {
  57. post.collectionAlias = nil
  58. publishPost()
  59. } else {
  60. self.model.isPresentingSettingsView = true
  61. }
  62. }, label: {
  63. Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
  64. })
  65. ForEach(collections) { collection in
  66. Button(action: {
  67. if model.account.isLoggedIn {
  68. post.collectionAlias = collection.alias
  69. publishPost()
  70. } else {
  71. self.model.isPresentingSettingsView = true
  72. }
  73. }, label: {
  74. Text(" \(collection.title)")
  75. })
  76. }
  77. }, label: {
  78. Label("Publish…", systemImage: "paperplane")
  79. })
  80. .accessibilityHint(Text("Choose the blog you want to publish this post to"))
  81. .disabled(post.body.count == 0)
  82. } else {
  83. Button(action: {
  84. if model.account.isLoggedIn {
  85. publishPost()
  86. } else {
  87. self.model.isPresentingSettingsView = true
  88. }
  89. }, label: {
  90. Label("Publish", systemImage: "paperplane")
  91. })
  92. .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0)
  93. }
  94. Button(action: {
  95. sharePost()
  96. }, label: {
  97. Label("Share", systemImage: "square.and.arrow.up")
  98. })
  99. .accessibilityHint(Text("Open the system share sheet to share a link to this post"))
  100. .disabled(post.postId == nil)
  101. if model.account.isLoggedIn && post.status != PostStatus.local.rawValue {
  102. Section(header: Text("Move To Collection")) {
  103. Label("Move to:", systemImage: "arrowshape.zigzag.right")
  104. Picker(selection: $selectedCollection, label: Text("Move to…")) {
  105. Text(
  106. " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")"
  107. ).tag(nil as WFACollection?)
  108. ForEach(collections) { collection in
  109. Text(" \(collection.title)").tag(collection as WFACollection?)
  110. }
  111. }
  112. }
  113. }
  114. }, label: {
  115. ZStack {
  116. Image("does.not.exist")
  117. .accessibilityHidden(true)
  118. Image(systemName: "ellipsis.circle")
  119. .imageScale(.large)
  120. .accessibilityHidden(true)
  121. }
  122. })
  123. .accessibilityLabel(Text("Menu"))
  124. .accessibilityHint(Text("Opens a context menu to publish, share, or move the post"))
  125. .onTapGesture {
  126. hideKeyboard()
  127. }
  128. .disabled(post.body.count == 0)
  129. }
  130. }
  131. }
  132. .onChange(of: post.hasNewerRemoteCopy, perform: { _ in
  133. if !post.hasNewerRemoteCopy {
  134. updatingTitleFromServer = true
  135. updatingBodyFromServer = true
  136. }
  137. })
  138. .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
  139. if post.collectionAlias == newCollection?.alias {
  140. return
  141. } else {
  142. post.collectionAlias = newCollection?.alias
  143. model.move(post: post, from: selectedCollection, to: newCollection)
  144. }
  145. })
  146. .onChange(of: post.status, perform: { value in
  147. if value != PostStatus.published.rawValue {
  148. self.model.editor.saveLastDraft(post)
  149. } else {
  150. self.model.editor.clearLastDraft()
  151. }
  152. DispatchQueue.main.async {
  153. LocalStorageManager.standard.saveContext()
  154. }
  155. })
  156. .onAppear(perform: {
  157. self.selectedCollection = collections.first { $0.alias == post.collectionAlias }
  158. if post.status != PostStatus.published.rawValue {
  159. DispatchQueue.main.async {
  160. self.model.editor.saveLastDraft(post)
  161. }
  162. } else {
  163. self.model.editor.clearLastDraft()
  164. }
  165. })
  166. .onChange(of: model.hasError) { value in
  167. if value {
  168. if let error = model.currentError {
  169. self.errorHandling.handle(error: error)
  170. } else {
  171. self.errorHandling.handle(error: AppError.genericError())
  172. }
  173. model.hasError = false
  174. }
  175. }
  176. .onDisappear(perform: {
  177. self.model.editor.clearLastDraft()
  178. if post.title.count == 0
  179. && post.body.count == 0
  180. && post.status == PostStatus.local.rawValue
  181. && post.updatedDate == nil
  182. && post.postId == nil {
  183. DispatchQueue.main.async {
  184. model.posts.remove(post)
  185. }
  186. } else if post.status != PostStatus.published.rawValue {
  187. DispatchQueue.main.async {
  188. LocalStorageManager.standard.saveContext()
  189. }
  190. }
  191. })
  192. }
  193. private func publishPost() {
  194. DispatchQueue.main.async {
  195. LocalStorageManager.standard.saveContext()
  196. model.publish(post: post)
  197. }
  198. #if os(iOS)
  199. self.hideKeyboard()
  200. #endif
  201. }
  202. private func sharePost() {
  203. // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early.
  204. guard let postId = post.postId else { return }
  205. var urlString: String
  206. if let postSlug = post.slug,
  207. let postCollectionAlias = post.collectionAlias {
  208. // This post is in a collection, so share the URL as baseURL/postSlug.
  209. let urls = collections.filter { $0.alias == postCollectionAlias }
  210. let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/"
  211. urlString = "\(baseURL)\(postSlug)"
  212. } else {
  213. // This is a draft post, so share the URL as server/postID
  214. urlString = "\(model.account.server)/\(postId)"
  215. }
  216. guard let data = URL(string: urlString) else { return }
  217. let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil)
  218. UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil)
  219. if UIDevice.current.userInterfaceIdiom == .pad {
  220. activityView.popoverPresentationController?.permittedArrowDirections = .up
  221. activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first
  222. activityView.popoverPresentationController?.sourceRect = CGRect(
  223. x: UIScreen.main.bounds.width,
  224. y: -125,
  225. width: 200,
  226. height: 200
  227. )
  228. }
  229. }
  230. }
  231. struct PostEditorView_EmptyPostPreviews: PreviewProvider {
  232. static var previews: some View {
  233. let context = LocalStorageManager.standard.container.viewContext
  234. let testPost = WFAPost(context: context)
  235. testPost.createdDate = Date()
  236. testPost.appearance = "norm"
  237. let model = WriteFreelyModel()
  238. return PostEditorView(post: testPost)
  239. .environment(\.managedObjectContext, context)
  240. .environmentObject(model)
  241. }
  242. }
  243. struct PostEditorView_ExistingPostPreviews: PreviewProvider {
  244. static var previews: some View {
  245. let context = LocalStorageManager.standard.container.viewContext
  246. let testPost = WFAPost(context: context)
  247. testPost.title = "Test Post Title"
  248. testPost.body = "Here's some cool sample body text."
  249. testPost.createdDate = Date()
  250. testPost.appearance = "code"
  251. testPost.hasNewerRemoteCopy = true
  252. let model = WriteFreelyModel()
  253. return PostEditorView(post: testPost)
  254. .environment(\.managedObjectContext, context)
  255. .environmentObject(model)
  256. }
  257. }