NotificationListView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. @ObservedObject var notificationsModel: NotificationsObservable
  12. @State private var editMode = EditMode.inactive
  13. @State private var selection = Set<Notification>()
  14. @State private var showAlert = false
  15. @State private var activeAlert: ActiveAlert = .clear
  16. private var subscriptionManager: SubscriptionManager {
  17. return SubscriptionManager(store: store)
  18. }
  19. init(subscription: Subscription) {
  20. self.subscription = subscription
  21. self.notificationsModel = NotificationsObservable(subscriptionID: subscription.objectID)
  22. }
  23. var body: some View {
  24. if #available(iOS 15.0, *) {
  25. notificationList
  26. .refreshable {
  27. subscriptionManager.poll(subscription)
  28. }
  29. } else {
  30. notificationList
  31. }
  32. }
  33. private var notificationList: some View {
  34. List(selection: $selection) {
  35. ForEach(notificationsModel.notifications, id: \.self) { notification in
  36. NotificationRowView(notification: notification)
  37. }
  38. }
  39. .listStyle(PlainListStyle())
  40. .navigationBarTitleDisplayMode(.inline)
  41. .environment(\.editMode, self.$editMode)
  42. .navigationBarBackButtonHidden(true)
  43. .toolbar {
  44. ToolbarItem(placement: .navigationBarLeading) {
  45. if (self.editMode != .active) {
  46. Button(action: {
  47. // iOS bug (?): We create a custom back button, because the original back button doesn't reset
  48. // selectedBaseUrl early enough and the row stays highlighted for a long time,
  49. // which is weird and feels wrong. This avoids that behavior.
  50. self.delegate.selectedBaseUrl = nil
  51. }){
  52. Image(systemName: "chevron.left")
  53. }
  54. .padding([.top, .bottom, .trailing], 40)
  55. }
  56. }
  57. ToolbarItem(placement: .principal) {
  58. Text(subscription.topicName())
  59. .font(.headline)
  60. .lineLimit(1)
  61. }
  62. ToolbarItem(placement: .navigationBarTrailing) {
  63. if (self.editMode == .active) {
  64. editButton
  65. } else {
  66. Menu {
  67. if #unavailable(iOS 15.0) {
  68. Button("Refresh") {
  69. subscriptionManager.poll(subscription)
  70. }
  71. }
  72. if notificationsModel.notifications.count > 0 {
  73. editButton
  74. }
  75. Button("Send test notification") {
  76. self.sendTestNotification()
  77. }
  78. if notificationsModel.notifications.count > 0 {
  79. Button("Clear all notifications") {
  80. self.showAlert = true
  81. self.activeAlert = .clear
  82. }
  83. }
  84. Button("Unsubscribe") {
  85. self.showAlert = true
  86. self.activeAlert = .unsubscribe
  87. }
  88. } label: {
  89. Image(systemName: "ellipsis.circle")
  90. .padding([.leading], 40)
  91. }
  92. }
  93. }
  94. ToolbarItem(placement: .navigationBarLeading) {
  95. if (self.editMode == .active) {
  96. Button(action: {
  97. self.showAlert = true
  98. self.activeAlert = .selected
  99. }) {
  100. Text("Delete")
  101. .foregroundColor(.red)
  102. }
  103. }
  104. }
  105. }
  106. .alert(isPresented: $showAlert) {
  107. switch activeAlert {
  108. case .clear:
  109. return Alert(
  110. title: Text("Clear notifications"),
  111. message: Text("Do you really want to delete all of the notifications in this topic?"),
  112. primaryButton: .destructive(
  113. Text("Permanently delete"),
  114. action: deleteAll
  115. ),
  116. secondaryButton: .cancel())
  117. case .unsubscribe:
  118. return Alert(
  119. title: Text("Unsubscribe"),
  120. message: Text("Do you really want to unsubscribe from this topic and delete all of the notifications you received?"),
  121. primaryButton: .destructive(
  122. Text("Unsubscribe"),
  123. action: unsubscribe
  124. ),
  125. secondaryButton: .cancel())
  126. case .selected:
  127. return Alert(
  128. title: Text("Delete"),
  129. message: Text("Do you really want to delete these selected notifications?"),
  130. primaryButton: .destructive(
  131. Text("Delete"),
  132. action: deleteSelected
  133. ),
  134. secondaryButton: .cancel())
  135. }
  136. }
  137. .overlay(Group {
  138. if notificationsModel.notifications.count == 0 {
  139. VStack {
  140. Text("You haven't received any notifications for this topic yet.")
  141. .font(.title2)
  142. .foregroundColor(.gray)
  143. .multilineTextAlignment(.center)
  144. .padding(.bottom)
  145. if #available(iOS 15.0, *) {
  146. 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).")
  147. .foregroundColor(.gray)
  148. } else {
  149. 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.")
  150. .foregroundColor(.gray)
  151. }
  152. }
  153. .padding(40)
  154. }
  155. })
  156. .onAppear {
  157. cancelSubscriptionNotifications()
  158. }
  159. }
  160. private var editButton: some View {
  161. if editMode == .inactive {
  162. return Button(action: {
  163. self.editMode = .active
  164. self.selection = Set<Notification>()
  165. }) {
  166. Text("Select messages")
  167. }
  168. } else {
  169. return Button(action: {
  170. self.editMode = .inactive
  171. self.selection = Set<Notification>()
  172. }) {
  173. Text("Done")
  174. }
  175. }
  176. }
  177. private func sendTestNotification() {
  178. 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"]
  179. let priority = Int.random(in: 1..<6)
  180. let tags = Array(possibleTags.shuffled().prefix(Int.random(in: 0..<4)))
  181. DispatchQueue.global(qos: .background).async {
  182. let user = store.getUser(baseUrl: subscription.baseUrl!)?.toBasicUser()
  183. ApiService.shared.publish(
  184. subscription: subscription,
  185. user: user,
  186. 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.",
  187. title: "Test: You can set a title if you like",
  188. priority: priority,
  189. tags: tags
  190. )
  191. }
  192. }
  193. private func unsubscribe() {
  194. DispatchQueue.global(qos: .background).async {
  195. subscriptionManager.unsubscribe(subscription)
  196. }
  197. delegate.selectedBaseUrl = nil
  198. }
  199. private func deleteAll() {
  200. DispatchQueue.global(qos: .background).async {
  201. store.delete(allNotificationsFor: subscription)
  202. }
  203. }
  204. private func deleteSelected() {
  205. DispatchQueue.global(qos: .background).async {
  206. store.delete(notifications: selection)
  207. selection = Set<Notification>()
  208. }
  209. editMode = .inactive
  210. }
  211. private func cancelSubscriptionNotifications() {
  212. let notificationCenter = UNUserNotificationCenter.current()
  213. notificationCenter.getDeliveredNotifications { notifications in
  214. let ids = notifications
  215. .filter { notification in
  216. let userInfo = notification.request.content.userInfo
  217. if let baseUrl = userInfo["base_url"] as? String, let topic = userInfo["topic"] as? String {
  218. return baseUrl == subscription.baseUrl && topic == subscription.topic
  219. }
  220. return false
  221. }
  222. .map { notification in
  223. notification.request.identifier
  224. }
  225. if !ids.isEmpty {
  226. Log.d(tag, "Cancelling \(ids.count) notification(s) from notification center")
  227. notificationCenter.removeDeliveredNotifications(withIdentifiers: ids)
  228. }
  229. }
  230. }
  231. }
  232. struct NotificationRowView: View {
  233. @EnvironmentObject private var store: Store
  234. @ObservedObject var notification: Notification
  235. var body: some View {
  236. if #available(iOS 15.0, *) {
  237. notificationRow
  238. .swipeActions(edge: .trailing) {
  239. Button(role: .destructive) {
  240. store.delete(notification: notification)
  241. } label: {
  242. Label("Delete", systemImage: "trash.circle")
  243. }
  244. }
  245. } else {
  246. notificationRow
  247. }
  248. }
  249. private var notificationRow: some View {
  250. VStack(alignment: .leading, spacing: 0) {
  251. HStack(alignment: .center, spacing: 2) {
  252. Text(notification.shortDateTime())
  253. .font(.subheadline)
  254. .foregroundColor(.gray)
  255. if [1,2,4,5].contains(notification.priority) {
  256. Image("priority-\(notification.priority)")
  257. .resizable()
  258. .scaledToFit()
  259. .frame(width: 16, height: 16)
  260. }
  261. }
  262. .padding([.bottom], 2)
  263. if let title = notification.formatTitle(), title != "" {
  264. Text(title)
  265. .font(.headline)
  266. .bold()
  267. .padding([.bottom], 2)
  268. }
  269. Text(notification.formatMessage())
  270. .font(.body)
  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 NotificationListView_Previews: PreviewProvider {
  313. static var previews: some View {
  314. let store = Store.preview
  315. Group {
  316. let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleMessages["stats"]!)
  317. let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleMessages["announcements"]!)
  318. NotificationListView(subscription: subscriptionWithNotifications)
  319. .environment(\.managedObjectContext, store.context)
  320. .environmentObject(store)
  321. NotificationListView(subscription: subscriptionWithoutNotifications)
  322. .environment(\.managedObjectContext, store.context)
  323. .environmentObject(store)
  324. }
  325. }
  326. }