123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- 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.topicName())
- .font(.headline)
- .lineLimit(1)
- }
- 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 {
- let user = store.getUser(baseUrl: subscription.baseUrl!)?.toBasicUser()
- ApiService.shared.publish(
- subscription: subscription,
- user: user,
- 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
- let userInfo = notification.request.content.userInfo
- if let baseUrl = userInfo["base_url"] as? String, let topic = userInfo["topic"] as? String {
- return baseUrl == subscription.baseUrl && topic == subscription.topic
- }
- 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 let attachment = notification.attachment {
- NotificationAttachmentView(notification: notification, attachment: attachment)
- .padding([.top, .bottom], 10)
- //.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity, minHeight: 0, idealHeight: .infinity, maxHeight: .infinity, alignment: .leading)
- }
- 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(.borderedProminent)
- } else {
- Button(action: {
- ActionExecutor.execute(action)
- }) {
- Text(action.label)
- .padding(EdgeInsets(top: 10.0, leading: 10.0, bottom: 10.0, trailing: 10.0))
- .foregroundColor(.white)
- .overlay(
- RoundedRectangle(cornerRadius: 10)
- .stroke(Color.white, lineWidth: 2)
- )
- }
- .background(Color.accentColor)
- .cornerRadius(10)
- }
- }
- }
- .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 NotificationAttachmentView: View {
- private let tag = "NotificationAttachmentView"
- @ObservedObject var notification: Notification
- @ObservedObject var attachment: Attachment
- @EnvironmentObject private var store: Store
-
- var body: some View {
- VStack {
- if let image = attachment.asImage() {
- image
- .resizable()
- .scaledToFill()
- //.frame(maxWidth: .infinity, maxHeight: 200)
- //.clipped()
- .background(Color.red)
- }
-
- Menu {
- if attachment.isDownloaded() {
- Button {
- // FIXME
- } label: {
- Text("Open file")
- }
- Button {
- // FIXME
- do {
- let fileManager = FileManager.default
- let contentUrl = try attachment.contentUrl.orThrow().toURL()
- let contentData = try Data(contentsOf: contentUrl)
- let targetUrl = try fileManager
- .url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
- .appendingPathComponent(attachment.name ?? "attachment" + contentData.guessExtension())
- Log.d(tag, "Saving file to \(targetUrl)")
- try contentData.write(to: targetUrl)
- Log.d(tag, "Saved file to \(targetUrl)")
- } catch {
- Log.w(tag, "Unable to save filexx", error)
- }
- } label: {
- Text("Save file")
- }
- Button {
- // FIXME
- do {
- let fileManager = FileManager.default
- let contentUrl = try attachment.contentUrl.orThrow().toURL()
- Log.d(tag, "Deleting file \(contentUrl.path)")
- try? fileManager.removeItem(atPath: contentUrl.path)
- attachment.contentUrl = nil
- store.save()
- } catch {
- Log.w(tag, "Unable to delete file", error)
- }
- } label: {
- Text("Delete file")
- }
- } else if !attachment.isExpired() {
- Button {
- if let url = attachment.url, let id = notification.id {
- AttachmentManager.download(url: url, id: id, maxLength: 0, timeout: .infinity) { contentUrl, error in
- DispatchQueue.main.async {
- attachment.contentUrl = contentUrl?.path // May be nil!
- store.save()
- }
- }
- }
- } label: {
- Text("Download file")
- }
- }
- } label: {
- if let image = attachment.asImage() {
- image
- .resizable()
- .scaledToFit()
- } else {
- NotificationAttachmentDetailView(attachment: attachment)
- }
- }
- }
- }
- }
- struct NotificationAttachmentDetailView: View {
- @ObservedObject var attachment: Attachment
-
- var body: some View {
- HStack {
- Image(systemName: "paperclip")
- VStack(alignment: .leading) {
- Text(attachment.name ?? "?")
- .font(.footnote)
- HStack {
- if let size = attachment.sizeString() {
- Text(size)
- .font(.footnote)
- .foregroundColor(.gray)
- }
- if (attachment.isDownloaded()) {
- Text("Downloaded")
- .font(.footnote)
- .foregroundColor(.gray)
- } else {
- Text("Not downloaded")
- .font(.footnote)
- .foregroundColor(.gray)
- Text(attachment.expiresString())
- .font(.footnote)
- .foregroundColor(.gray)
- }
- }
- }
- }
- .foregroundColor(.primary)
- }
- }
- struct NotificationListView_Previews: PreviewProvider {
- static var previews: some View {
- let store = Store.preview
- Group {
- let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleMessages["stats"]!)
- let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleMessages["announcements"]!)
- NotificationListView(subscription: subscriptionWithNotifications)
- .environment(\.managedObjectContext, store.context)
- .environmentObject(store)
- NotificationListView(subscription: subscriptionWithoutNotifications)
- .environment(\.managedObjectContext, store.context)
- .environmentObject(store)
- }
- }
- }
|