123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- import SwiftUI
- import UniformTypeIdentifiers
- enum ActiveAlert {
- case clear, unsubscribe, selected
- }
- struct NotificationListView: View {
- private let tag = "NotificationListView"
-
- @EnvironmentObject private var delegate: AppDelegate
- @EnvironmentObject private var store: Store
-
- @ObservedObject var subscription: Subscription
-
- @State private var editMode = EditMode.inactive
- @State private var selection = Set<Notification>()
-
- @State private var showAlert = false
- @State private var activeAlert: ActiveAlert = .clear
-
- private var subscriptionManager: SubscriptionManager {
- return SubscriptionManager(store: store)
- }
-
- var body: some View {
- if #available(iOS 15.0, *) {
- notificationList
- .refreshable {
- subscriptionManager.poll(subscription)
- }
- } else {
- notificationList
- }
- }
-
- private var notificationList: some View {
- List(selection: $selection) {
- ForEach(subscription.notificationsSorted(), id: \.self) { notification in
- NotificationRowView(notification: notification)
- }
- }
- .listStyle(PlainListStyle())
- .navigationBarTitleDisplayMode(.inline)
- .environment(\.editMode, self.$editMode)
- .navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- if (self.editMode != .active) {
- Button(action: {
- // iOS bug (?): We create a custom back button, because the original back button doesn't reset
- // selectedBaseUrl early enough and the row stays highlighted for a long time,
- // which is weird and feels wrong. This avoids that behavior.
-
- self.delegate.selectedBaseUrl = nil
- }){
- Image(systemName: "chevron.left")
- }
- .padding([.top, .bottom, .trailing], 40)
- }
- }
- ToolbarItem(placement: .principal) {
- Text(subscription.displayName()).font(.headline)
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- if (self.editMode == .active) {
- editButton
- } else {
- Menu {
- if #unavailable(iOS 15.0) {
- Button("Refresh") {
- subscriptionManager.poll(subscription)
- }
- }
- if subscription.notificationCount() > 0 {
- editButton
- }
- Button("Send test notification") {
- self.sendTestNotification()
- }
- if subscription.notificationCount() > 0 {
- Button("Clear all notifications") {
- self.showAlert = true
- self.activeAlert = .clear
- }
- }
- Button("Unsubscribe") {
- self.showAlert = true
- self.activeAlert = .unsubscribe
- }
- } label: {
- Image(systemName: "ellipsis.circle")
- .padding([.leading], 40)
- }
- }
- }
- ToolbarItem(placement: .navigationBarLeading) {
- if (self.editMode == .active) {
- Button(action: {
- self.showAlert = true
- self.activeAlert = .selected
- }) {
- Text("Delete")
- .foregroundColor(.red)
- }
- }
- }
- }
- .alert(isPresented: $showAlert) {
- switch activeAlert {
- case .clear:
- return Alert(
- title: Text("Clear notifications"),
- message: Text("Do you really want to delete all of the notifications in this topic?"),
- primaryButton: .destructive(
- Text("Permanently delete"),
- action: deleteAll
- ),
- secondaryButton: .cancel())
- case .unsubscribe:
- return Alert(
- title: Text("Unsubscribe"),
- message: Text("Do you really want to unsubscribe from this topic and delete all of the notifications you received?"),
- primaryButton: .destructive(
- Text("Unsubscribe"),
- action: unsubscribe
- ),
- secondaryButton: .cancel())
- case .selected:
- return Alert(
- title: Text("Delete"),
- message: Text("Do you really want to delete these selected notifications?"),
- primaryButton: .destructive(
- Text("Delete"),
- action: deleteSelected
- ),
- secondaryButton: .cancel())
- }
- }
- .overlay(Group {
- if subscription.notificationCount() == 0 {
- VStack {
- Text("You haven't received any notifications for this topic yet.")
- .font(.title2)
- .foregroundColor(.gray)
- .multilineTextAlignment(.center)
- .padding(.bottom)
-
- if #available(iOS 15.0, *) {
- 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).")
- .foregroundColor(.gray)
- } else {
- 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.")
- .foregroundColor(.gray)
- }
- }
- .padding(40)
- }
- })
- .onAppear {
- cancelSubscriptionNotifications()
- }
- }
-
- private var editButton: some View {
- if editMode == .inactive {
- return Button(action: {
- self.editMode = .active
- self.selection = Set<Notification>()
- }) {
- Text("Select messages")
- }
- } else {
- return Button(action: {
- self.editMode = .inactive
- self.selection = Set<Notification>()
- }) {
- Text("Done")
- }
- }
- }
-
- private func sendTestNotification() {
- 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"]
- let priority = Int.random(in: 1..<6)
- let tags = Array(possibleTags.shuffled().prefix(Int.random(in: 0..<4)))
- DispatchQueue.global(qos: .background).async {
- ApiService.shared.publish(
- subscription: subscription,
- 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.",
- title: "Test: You can set a title if you like",
- priority: priority,
- tags: tags
- )
- }
- }
-
- private func unsubscribe() {
- DispatchQueue.global(qos: .background).async {
- subscriptionManager.unsubscribe(subscription)
- }
- delegate.selectedBaseUrl = nil
- }
-
- private func deleteAll() {
- DispatchQueue.global(qos: .background).async {
- store.delete(allNotificationsFor: subscription)
- }
- }
-
- private func deleteSelected() {
- DispatchQueue.global(qos: .background).async {
- store.delete(notifications: selection)
- selection = Set<Notification>()
- }
- editMode = .inactive
- }
-
- private func cancelSubscriptionNotifications() {
- let notificationCenter = UNUserNotificationCenter.current()
- notificationCenter.getDeliveredNotifications { notifications in
- let ids = notifications
- .filter { notification in
- if let topic = notification.request.content.userInfo["topic"] as? String {
- return topic == subscription.topic // TODO: This is not enough for selfhosted servers
- }
- return false
- }
- .map { notification in
- notification.request.identifier
- }
- if !ids.isEmpty {
- Log.d(tag, "Cancelling \(ids.count) notification(s) from notification center")
- notificationCenter.removeDeliveredNotifications(withIdentifiers: ids)
- }
- }
- }
- }
- struct NotificationRowView: View {
- @EnvironmentObject private var store: Store
- @ObservedObject var notification: Notification
-
- var body: some View {
- if #available(iOS 15.0, *) {
- notificationRow
- .swipeActions(edge: .trailing) {
- Button(role: .destructive) {
- store.delete(notification: notification)
- } label: {
- Label("Delete", systemImage: "trash.circle")
- }
- }
- } else {
- notificationRow
- }
- }
-
- private var notificationRow: some View {
- VStack(alignment: .leading, spacing: 0) {
- HStack(alignment: .center, spacing: 2) {
- Text(notification.shortDateTime())
- .font(.subheadline)
- .foregroundColor(.gray)
- if [1,2,4,5].contains(notification.priority) {
- Image("priority-\(notification.priority)")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- }
- }
- .padding([.bottom], 2)
- if let title = notification.formatTitle(), title != "" {
- Text(title)
- .font(.headline)
- .bold()
- .padding([.bottom], 2)
- }
- Text(notification.formatMessage())
- .font(.body)
- if !notification.nonEmojiTags().isEmpty {
- Text("Tags: " + notification.nonEmojiTags().joined(separator: ", "))
- .font(.subheadline)
- .foregroundColor(.gray)
- .padding([.top], 2)
- }
- if !notification.actionsList().isEmpty {
- HStack {
- ForEach(notification.actionsList()) { action in
- if #available(iOS 15, *) {
- Button(action.label) {
- ActionExecutor.execute(action)
- }
- .buttonStyle(.bordered)
- } else {
- Button(action.label) {
- ActionExecutor.execute(action)
- }
- }
- }
- }
- .padding([.top], 5)
- }
- }
- .padding(.all, 4)
- .onTapGesture {
- // TODO: This gives no feedback to the user, and it only works if the text is tapped
- UIPasteboard.general.setValue(notification.formatMessage(), forPasteboardType: UTType.plainText.identifier)
- }
- }
- }
- struct NotificationListView_Previews: PreviewProvider {
- static var previews: some View {
- let store = Store.preview
- Group {
- let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleData["stats"]!)
- let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleData["announcements"]!)
- NotificationListView(subscription: subscriptionWithNotifications)
- .environment(\.managedObjectContext, store.context)
- .environmentObject(store)
- NotificationListView(subscription: subscriptionWithoutNotifications)
- .environment(\.managedObjectContext, store.context)
- .environmentObject(store)
- }
- }
- }
|