NotificationListView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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 !notification.nonEmojiTags().isEmpty {
  267. Text("Tags: " + notification.nonEmojiTags().joined(separator: ", "))
  268. .font(.subheadline)
  269. .foregroundColor(.gray)
  270. .padding([.top], 2)
  271. }
  272. if !notification.actionsList().isEmpty {
  273. HStack {
  274. ForEach(notification.actionsList()) { action in
  275. if #available(iOS 15, *) {
  276. Button(action.label) {
  277. ActionExecutor.execute(action)
  278. }
  279. .buttonStyle(.borderedProminent)
  280. } else {
  281. Button(action: {
  282. ActionExecutor.execute(action)
  283. }) {
  284. Text(action.label)
  285. .padding(EdgeInsets(top: 10.0, leading: 10.0, bottom: 10.0, trailing: 10.0))
  286. .foregroundColor(.white)
  287. .overlay(
  288. RoundedRectangle(cornerRadius: 10)
  289. .stroke(Color.white, lineWidth: 2)
  290. )
  291. }
  292. .background(Color.accentColor)
  293. .cornerRadius(10)
  294. }
  295. }
  296. }
  297. .padding([.top], 5)
  298. }
  299. }
  300. .padding(.all, 4)
  301. .onTapGesture {
  302. // TODO: This gives no feedback to the user, and it only works if the text is tapped
  303. UIPasteboard.general.setValue(notification.formatMessage(), forPasteboardType: UTType.plainText.identifier)
  304. }
  305. }
  306. }
  307. struct NotificationListView_Previews: PreviewProvider {
  308. static var previews: some View {
  309. let store = Store.preview
  310. Group {
  311. let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleMessages["stats"]!)
  312. let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleMessages["announcements"]!)
  313. NotificationListView(subscription: subscriptionWithNotifications)
  314. .environment(\.managedObjectContext, store.context)
  315. .environmentObject(store)
  316. NotificationListView(subscription: subscriptionWithoutNotifications)
  317. .environment(\.managedObjectContext, store.context)
  318. .environmentObject(store)
  319. }
  320. }
  321. }