NotificationListView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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.displayName()).font(.headline)
  54. }
  55. ToolbarItem(placement: .navigationBarTrailing) {
  56. if (self.editMode == .active) {
  57. editButton
  58. } else {
  59. Menu {
  60. if #unavailable(iOS 15.0) {
  61. Button("Refresh") {
  62. subscriptionManager.poll(subscription)
  63. }
  64. }
  65. if subscription.notificationCount() > 0 {
  66. editButton
  67. }
  68. Button("Send test notification") {
  69. self.sendTestNotification()
  70. }
  71. if subscription.notificationCount() > 0 {
  72. Button("Clear all notifications") {
  73. self.showAlert = true
  74. self.activeAlert = .clear
  75. }
  76. }
  77. Button("Unsubscribe") {
  78. self.showAlert = true
  79. self.activeAlert = .unsubscribe
  80. }
  81. } label: {
  82. Image(systemName: "ellipsis.circle")
  83. .padding([.leading], 40)
  84. }
  85. }
  86. }
  87. ToolbarItem(placement: .navigationBarLeading) {
  88. if (self.editMode == .active) {
  89. Button(action: {
  90. self.showAlert = true
  91. self.activeAlert = .selected
  92. }) {
  93. Text("Delete")
  94. .foregroundColor(.red)
  95. }
  96. }
  97. }
  98. }
  99. .alert(isPresented: $showAlert) {
  100. switch activeAlert {
  101. case .clear:
  102. return Alert(
  103. title: Text("Clear notifications"),
  104. message: Text("Do you really want to delete all of the notifications in this topic?"),
  105. primaryButton: .destructive(
  106. Text("Permanently delete"),
  107. action: deleteAll
  108. ),
  109. secondaryButton: .cancel())
  110. case .unsubscribe:
  111. return Alert(
  112. title: Text("Unsubscribe"),
  113. message: Text("Do you really want to unsubscribe from this topic and delete all of the notifications you received?"),
  114. primaryButton: .destructive(
  115. Text("Unsubscribe"),
  116. action: unsubscribe
  117. ),
  118. secondaryButton: .cancel())
  119. case .selected:
  120. return Alert(
  121. title: Text("Delete"),
  122. message: Text("Do you really want to delete these selected notifications?"),
  123. primaryButton: .destructive(
  124. Text("Delete"),
  125. action: deleteSelected
  126. ),
  127. secondaryButton: .cancel())
  128. }
  129. }
  130. .overlay(Group {
  131. if subscription.notificationCount() == 0 {
  132. VStack {
  133. Text("You haven't received any notifications for this topic yet.")
  134. .font(.title2)
  135. .foregroundColor(.gray)
  136. .multilineTextAlignment(.center)
  137. .padding(.bottom)
  138. if #available(iOS 15.0, *) {
  139. 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).")
  140. .foregroundColor(.gray)
  141. } else {
  142. 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.")
  143. .foregroundColor(.gray)
  144. }
  145. }
  146. .padding(40)
  147. }
  148. })
  149. .onAppear {
  150. cancelSubscriptionNotifications()
  151. }
  152. }
  153. private var editButton: some View {
  154. if editMode == .inactive {
  155. return Button(action: {
  156. self.editMode = .active
  157. self.selection = Set<Notification>()
  158. }) {
  159. Text("Select messages")
  160. }
  161. } else {
  162. return Button(action: {
  163. self.editMode = .inactive
  164. self.selection = Set<Notification>()
  165. }) {
  166. Text("Done")
  167. }
  168. }
  169. }
  170. private func sendTestNotification() {
  171. 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"]
  172. let priority = Int.random(in: 1..<6)
  173. let tags = Array(possibleTags.shuffled().prefix(Int.random(in: 0..<4)))
  174. DispatchQueue.global(qos: .background).async {
  175. ApiService.shared.publish(
  176. subscription: subscription,
  177. 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.",
  178. title: "Test: You can set a title if you like",
  179. priority: priority,
  180. tags: tags
  181. )
  182. }
  183. }
  184. private func unsubscribe() {
  185. DispatchQueue.global(qos: .background).async {
  186. subscriptionManager.unsubscribe(subscription)
  187. }
  188. delegate.selectedBaseUrl = nil
  189. }
  190. private func deleteAll() {
  191. DispatchQueue.global(qos: .background).async {
  192. store.delete(allNotificationsFor: subscription)
  193. }
  194. }
  195. private func deleteSelected() {
  196. DispatchQueue.global(qos: .background).async {
  197. store.delete(notifications: selection)
  198. selection = Set<Notification>()
  199. }
  200. editMode = .inactive
  201. }
  202. private func cancelSubscriptionNotifications() {
  203. let notificationCenter = UNUserNotificationCenter.current()
  204. notificationCenter.getDeliveredNotifications { notifications in
  205. let ids = notifications
  206. .filter { notification in
  207. if let topic = notification.request.content.userInfo["topic"] as? String {
  208. return topic == subscription.topic // TODO: This is not enough for selfhosted servers
  209. }
  210. return false
  211. }
  212. .map { notification in
  213. notification.request.identifier
  214. }
  215. if !ids.isEmpty {
  216. Log.d(tag, "Cancelling \(ids.count) notification(s) from notification center")
  217. notificationCenter.removeDeliveredNotifications(withIdentifiers: ids)
  218. }
  219. }
  220. }
  221. }
  222. struct NotificationRowView: View {
  223. @EnvironmentObject private var store: Store
  224. @ObservedObject var notification: Notification
  225. var body: some View {
  226. if #available(iOS 15.0, *) {
  227. notificationRow
  228. .swipeActions(edge: .trailing) {
  229. Button(role: .destructive) {
  230. store.delete(notification: notification)
  231. } label: {
  232. Label("Delete", systemImage: "trash.circle")
  233. }
  234. }
  235. } else {
  236. notificationRow
  237. }
  238. }
  239. private var notificationRow: some View {
  240. VStack(alignment: .leading, spacing: 0) {
  241. HStack(alignment: .center, spacing: 2) {
  242. Text(notification.shortDateTime())
  243. .font(.subheadline)
  244. .foregroundColor(.gray)
  245. if [1,2,4,5].contains(notification.priority) {
  246. Image("priority-\(notification.priority)")
  247. .resizable()
  248. .scaledToFit()
  249. .frame(width: 16, height: 16)
  250. }
  251. }
  252. .padding([.bottom], 2)
  253. if let title = notification.formatTitle(), title != "" {
  254. Text(title)
  255. .font(.headline)
  256. .bold()
  257. .padding([.bottom], 2)
  258. }
  259. Text(notification.formatMessage())
  260. .font(.body)
  261. if !notification.nonEmojiTags().isEmpty {
  262. Text("Tags: " + notification.nonEmojiTags().joined(separator: ", "))
  263. .font(.subheadline)
  264. .foregroundColor(.gray)
  265. .padding([.top], 2)
  266. }
  267. if !notification.actionsList().isEmpty {
  268. HStack {
  269. ForEach(notification.actionsList()) { action in
  270. if #available(iOS 15, *) {
  271. Button(action.label) {
  272. ActionExecutor.execute(action)
  273. }
  274. .buttonStyle(.bordered)
  275. } else {
  276. Button(action.label) {
  277. ActionExecutor.execute(action)
  278. }
  279. }
  280. }
  281. }
  282. .padding([.top], 5)
  283. }
  284. }
  285. .padding(.all, 4)
  286. .onTapGesture {
  287. // TODO: This gives no feedback to the user, and it only works if the text is tapped
  288. UIPasteboard.general.setValue(notification.formatMessage(), forPasteboardType: UTType.plainText.identifier)
  289. }
  290. }
  291. }
  292. struct NotificationListView_Previews: PreviewProvider {
  293. static var previews: some View {
  294. let store = Store.preview
  295. Group {
  296. let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleData["stats"]!)
  297. let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleData["announcements"]!)
  298. NotificationListView(subscription: subscriptionWithNotifications)
  299. .environment(\.managedObjectContext, store.context)
  300. .environmentObject(store)
  301. NotificationListView(subscription: subscriptionWithoutNotifications)
  302. .environment(\.managedObjectContext, store.context)
  303. .environmentObject(store)
  304. }
  305. }
  306. }