NotificationListView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import SwiftUI
  2. import UniformTypeIdentifiers
  3. enum ActiveAlert {
  4. case clear, unsubscribe, selected
  5. }
  6. struct NotificationListView: View {
  7. private let tag = "NotificationListView"
  8. @EnvironmentObject private var delegate: AppDelegate
  9. @EnvironmentObject private var store: Store
  10. @ObservedObject var subscription: Subscription
  11. @State private var editMode = EditMode.inactive
  12. @State private var selection = Set<Notification>()
  13. @State private var showAlert = false
  14. @State private var activeAlert: ActiveAlert = .clear
  15. private var subscriptionManager: SubscriptionManager {
  16. return SubscriptionManager(store: store)
  17. }
  18. var body: some View {
  19. if #available(iOS 15.0, *) {
  20. notificationList
  21. .refreshable {
  22. subscriptionManager.poll(subscription)
  23. }
  24. } else {
  25. notificationList
  26. }
  27. }
  28. private var notificationList: some View {
  29. List(selection: $selection) {
  30. ForEach(subscription.notificationsSorted(), id: \.self) { notification in
  31. NotificationRowView(notification: notification)
  32. }
  33. }
  34. .listStyle(PlainListStyle())
  35. .navigationBarTitleDisplayMode(.inline)
  36. .environment(\.editMode, self.$editMode)
  37. .navigationBarBackButtonHidden(true)
  38. .toolbar {
  39. ToolbarItem(placement: .navigationBarLeading) {
  40. if (self.editMode != .active) {
  41. Button(action: {
  42. // iOS bug (?): We create a custom back button, because the original back button doesn't reset
  43. // selectedBaseUrl early enough and the row stays highlighted for a long time,
  44. // which is weird and feels wrong. This avoids that behavior.
  45. self.delegate.selectedBaseUrl = nil
  46. }){
  47. Image(systemName: "chevron.left")
  48. }
  49. .padding([.top, .bottom, .trailing], 40)
  50. }
  51. }
  52. ToolbarItem(placement: .principal) {
  53. Text(subscription.topicName())
  54. .font(.headline)
  55. .lineLimit(1)
  56. }
  57. ToolbarItem(placement: .navigationBarTrailing) {
  58. if (self.editMode == .active) {
  59. editButton
  60. } else {
  61. Menu {
  62. if #unavailable(iOS 15.0) {
  63. Button("Refresh") {
  64. subscriptionManager.poll(subscription)
  65. }
  66. }
  67. if subscription.notificationCount() > 0 {
  68. editButton
  69. }
  70. Button("Send test notification") {
  71. self.sendTestNotification()
  72. }
  73. if subscription.notificationCount() > 0 {
  74. Button("Clear all notifications") {
  75. self.showAlert = true
  76. self.activeAlert = .clear
  77. }
  78. }
  79. Button("Unsubscribe") {
  80. self.showAlert = true
  81. self.activeAlert = .unsubscribe
  82. }
  83. } label: {
  84. Image(systemName: "ellipsis.circle")
  85. .padding([.leading], 40)
  86. }
  87. }
  88. }
  89. ToolbarItem(placement: .navigationBarLeading) {
  90. if (self.editMode == .active) {
  91. Button(action: {
  92. self.showAlert = true
  93. self.activeAlert = .selected
  94. }) {
  95. Text("Delete")
  96. .foregroundColor(.red)
  97. }
  98. }
  99. }
  100. }
  101. .alert(isPresented: $showAlert) {
  102. switch activeAlert {
  103. case .clear:
  104. return Alert(
  105. title: Text("Clear notifications"),
  106. message: Text("Do you really want to delete all of the notifications in this topic?"),
  107. primaryButton: .destructive(
  108. Text("Permanently delete"),
  109. action: deleteAll
  110. ),
  111. secondaryButton: .cancel())
  112. case .unsubscribe:
  113. return Alert(
  114. title: Text("Unsubscribe"),
  115. message: Text("Do you really want to unsubscribe from this topic and delete all of the notifications you received?"),
  116. primaryButton: .destructive(
  117. Text("Unsubscribe"),
  118. action: unsubscribe
  119. ),
  120. secondaryButton: .cancel())
  121. case .selected:
  122. return Alert(
  123. title: Text("Delete"),
  124. message: Text("Do you really want to delete these selected notifications?"),
  125. primaryButton: .destructive(
  126. Text("Delete"),
  127. action: deleteSelected
  128. ),
  129. secondaryButton: .cancel())
  130. }
  131. }
  132. .overlay(Group {
  133. if subscription.notificationCount() == 0 {
  134. VStack {
  135. Text("You haven't received any notifications for this topic yet.")
  136. .font(.title2)
  137. .foregroundColor(.gray)
  138. .multilineTextAlignment(.center)
  139. .padding(.bottom)
  140. if #available(iOS 15.0, *) {
  141. Text("To send notifications to this topic, simply PUT or POST to the topic URL.\n\nExample:\n`$ curl -d \"hi\" ntfy.sh/\(subscription.topicName())`\n\nDetailed instructions are available on [ntfy.sh](https://ntfy.sh) and [in the docs](https://ntfy.sh/docs).")
  142. .foregroundColor(.gray)
  143. } else {
  144. Text("To send notifications to this topic, simply PUT or POST to the topic URL.\n\nExample:\n`$ curl -d \"hi\" ntfy.sh/\(subscription.topicName())`\n\nDetailed instructions are available on https://ntfy.sh and https://ntfy.sh/docs.")
  145. .foregroundColor(.gray)
  146. }
  147. }
  148. .padding(40)
  149. }
  150. })
  151. .onAppear {
  152. cancelSubscriptionNotifications()
  153. }
  154. }
  155. private var editButton: some View {
  156. if editMode == .inactive {
  157. return Button(action: {
  158. self.editMode = .active
  159. self.selection = Set<Notification>()
  160. }) {
  161. Text("Select messages")
  162. }
  163. } else {
  164. return Button(action: {
  165. self.editMode = .inactive
  166. self.selection = Set<Notification>()
  167. }) {
  168. Text("Done")
  169. }
  170. }
  171. }
  172. private func sendTestNotification() {
  173. let possibleTags: Array<String> = ["warning", "skull", "success", "triangular_flag_on_post", "de", "us", "dog", "cat", "rotating_light", "bike", "backup", "rsync", "this-s-a-tag", "ios"]
  174. let priority = Int.random(in: 1..<6)
  175. let tags = Array(possibleTags.shuffled().prefix(Int.random(in: 0..<4)))
  176. DispatchQueue.global(qos: .background).async {
  177. let user = store.getUser(baseUrl: subscription.baseUrl!)?.toBasicUser()
  178. ApiService.shared.publish(
  179. subscription: subscription,
  180. user: user,
  181. message: "This is a test notification from the ntfy iOS app. It has a priority of \(priority). If you send another one, it may look different.",
  182. title: "Test: You can set a title if you like",
  183. priority: priority,
  184. tags: tags
  185. )
  186. }
  187. }
  188. private func unsubscribe() {
  189. DispatchQueue.global(qos: .background).async {
  190. subscriptionManager.unsubscribe(subscription)
  191. }
  192. delegate.selectedBaseUrl = nil
  193. }
  194. private func deleteAll() {
  195. DispatchQueue.global(qos: .background).async {
  196. store.delete(allNotificationsFor: subscription)
  197. }
  198. }
  199. private func deleteSelected() {
  200. DispatchQueue.global(qos: .background).async {
  201. store.delete(notifications: selection)
  202. selection = Set<Notification>()
  203. }
  204. editMode = .inactive
  205. }
  206. private func cancelSubscriptionNotifications() {
  207. let notificationCenter = UNUserNotificationCenter.current()
  208. notificationCenter.getDeliveredNotifications { notifications in
  209. let ids = notifications
  210. .filter { notification in
  211. let userInfo = notification.request.content.userInfo
  212. if let baseUrl = userInfo["base_url"] as? String, let topic = userInfo["topic"] as? String {
  213. return baseUrl == subscription.baseUrl && topic == subscription.topic
  214. }
  215. return false
  216. }
  217. .map { notification in
  218. notification.request.identifier
  219. }
  220. if !ids.isEmpty {
  221. Log.d(tag, "Cancelling \(ids.count) notification(s) from notification center")
  222. notificationCenter.removeDeliveredNotifications(withIdentifiers: ids)
  223. }
  224. }
  225. }
  226. }
  227. struct NotificationRowView: View {
  228. @EnvironmentObject private var store: Store
  229. @ObservedObject var notification: Notification
  230. var body: some View {
  231. if #available(iOS 15.0, *) {
  232. notificationRow
  233. .swipeActions(edge: .trailing) {
  234. Button(role: .destructive) {
  235. store.delete(notification: notification)
  236. } label: {
  237. Label("Delete", systemImage: "trash.circle")
  238. }
  239. }
  240. } else {
  241. notificationRow
  242. }
  243. }
  244. private var notificationRow: some View {
  245. VStack(alignment: .leading, spacing: 0) {
  246. HStack(alignment: .center, spacing: 2) {
  247. Text(notification.shortDateTime())
  248. .font(.subheadline)
  249. .foregroundColor(.gray)
  250. if [1,2,4,5].contains(notification.priority) {
  251. Image("priority-\(notification.priority)")
  252. .resizable()
  253. .scaledToFit()
  254. .frame(width: 16, height: 16)
  255. }
  256. }
  257. .padding([.bottom], 2)
  258. if let title = notification.formatTitle(), title != "" {
  259. Text(title)
  260. .font(.headline)
  261. .bold()
  262. .padding([.bottom], 2)
  263. }
  264. Text(notification.formatMessage())
  265. .font(.body)
  266. if let attachment = notification.attachment {
  267. NotificationAttachmentView(notification: notification, attachment: attachment)
  268. .padding([.top, .bottom], 10)
  269. //.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity, minHeight: 0, idealHeight: .infinity, maxHeight: .infinity, alignment: .leading)
  270. }
  271. if !notification.nonEmojiTags().isEmpty {
  272. Text("Tags: " + notification.nonEmojiTags().joined(separator: ", "))
  273. .font(.subheadline)
  274. .foregroundColor(.gray)
  275. .padding([.top], 2)
  276. }
  277. if !notification.actionsList().isEmpty {
  278. HStack {
  279. ForEach(notification.actionsList()) { action in
  280. if #available(iOS 15, *) {
  281. Button(action.label) {
  282. ActionExecutor.execute(action)
  283. }
  284. .buttonStyle(.borderedProminent)
  285. } else {
  286. Button(action: {
  287. ActionExecutor.execute(action)
  288. }) {
  289. Text(action.label)
  290. .padding(EdgeInsets(top: 10.0, leading: 10.0, bottom: 10.0, trailing: 10.0))
  291. .foregroundColor(.white)
  292. .overlay(
  293. RoundedRectangle(cornerRadius: 10)
  294. .stroke(Color.white, lineWidth: 2)
  295. )
  296. }
  297. .background(Color.accentColor)
  298. .cornerRadius(10)
  299. }
  300. }
  301. }
  302. .padding([.top], 5)
  303. }
  304. }
  305. .padding(.all, 4)
  306. .onTapGesture {
  307. // TODO: This gives no feedback to the user, and it only works if the text is tapped
  308. UIPasteboard.general.setValue(notification.formatMessage(), forPasteboardType: UTType.plainText.identifier)
  309. }
  310. }
  311. }
  312. struct NotificationAttachmentView: View {
  313. private let tag = "NotificationAttachmentView"
  314. @ObservedObject var notification: Notification
  315. @ObservedObject var attachment: Attachment
  316. @EnvironmentObject private var store: Store
  317. var body: some View {
  318. VStack {
  319. if let image = attachment.asImage() {
  320. image
  321. .resizable()
  322. .scaledToFill()
  323. //.frame(maxWidth: .infinity, maxHeight: 200)
  324. //.clipped()
  325. .background(Color.red)
  326. }
  327. Menu {
  328. if attachment.isDownloaded() {
  329. Button {
  330. // FIXME
  331. } label: {
  332. Text("Open file")
  333. }
  334. Button {
  335. // FIXME
  336. do {
  337. let fileManager = FileManager.default
  338. let contentUrl = try attachment.contentUrl.orThrow().toURL()
  339. let contentData = try Data(contentsOf: contentUrl)
  340. let targetUrl = try fileManager
  341. .url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
  342. .appendingPathComponent(attachment.name ?? "attachment" + contentData.guessExtension())
  343. Log.d(tag, "Saving file to \(targetUrl)")
  344. try contentData.write(to: targetUrl)
  345. Log.d(tag, "Saved file to \(targetUrl)")
  346. } catch {
  347. Log.w(tag, "Unable to save filexx", error)
  348. }
  349. } label: {
  350. Text("Save file")
  351. }
  352. Button {
  353. // FIXME
  354. do {
  355. let fileManager = FileManager.default
  356. let contentUrl = try attachment.contentUrl.orThrow().toURL()
  357. Log.d(tag, "Deleting file \(contentUrl.path)")
  358. try? fileManager.removeItem(atPath: contentUrl.path)
  359. attachment.contentUrl = nil
  360. store.save()
  361. } catch {
  362. Log.w(tag, "Unable to delete file", error)
  363. }
  364. } label: {
  365. Text("Delete file")
  366. }
  367. } else if !attachment.isExpired() {
  368. Button {
  369. if let url = attachment.url, let id = notification.id {
  370. AttachmentManager.download(url: url, id: id, maxLength: 0, timeout: .infinity) { contentUrl, error in
  371. DispatchQueue.main.async {
  372. attachment.contentUrl = contentUrl?.path // May be nil!
  373. store.save()
  374. }
  375. }
  376. }
  377. } label: {
  378. Text("Download file")
  379. }
  380. }
  381. } label: {
  382. if let image = attachment.asImage() {
  383. image
  384. .resizable()
  385. .scaledToFit()
  386. } else {
  387. NotificationAttachmentDetailView(attachment: attachment)
  388. }
  389. }
  390. }
  391. }
  392. }
  393. struct NotificationAttachmentDetailView: View {
  394. @ObservedObject var attachment: Attachment
  395. var body: some View {
  396. HStack {
  397. Image(systemName: "paperclip")
  398. VStack(alignment: .leading) {
  399. Text(attachment.name ?? "?")
  400. .font(.footnote)
  401. HStack {
  402. if let size = attachment.sizeString() {
  403. Text(size)
  404. .font(.footnote)
  405. .foregroundColor(.gray)
  406. }
  407. if (attachment.isDownloaded()) {
  408. Text("Downloaded")
  409. .font(.footnote)
  410. .foregroundColor(.gray)
  411. } else {
  412. Text("Not downloaded")
  413. .font(.footnote)
  414. .foregroundColor(.gray)
  415. Text(attachment.expiresString())
  416. .font(.footnote)
  417. .foregroundColor(.gray)
  418. }
  419. }
  420. }
  421. }
  422. .foregroundColor(.primary)
  423. }
  424. }
  425. struct NotificationListView_Previews: PreviewProvider {
  426. static var previews: some View {
  427. let store = Store.preview
  428. Group {
  429. let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleMessages["stats"]!)
  430. let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleMessages["announcements"]!)
  431. NotificationListView(subscription: subscriptionWithNotifications)
  432. .environment(\.managedObjectContext, store.context)
  433. .environmentObject(store)
  434. NotificationListView(subscription: subscriptionWithoutNotifications)
  435. .environment(\.managedObjectContext, store.context)
  436. .environmentObject(store)
  437. }
  438. }
  439. }