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.
 
 
 

269 lines
11 KiB

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