diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift index df3be30d7..68082a4af 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift @@ -4,47 +4,120 @@ import SwiftUI import Utils import Views + import Transmission - @MainActor final class PushNotificationSettingsViewModel: ObservableObject { - @Published var isLoading = false - @Published var emails = [NewsletterEmail]() - @Published var desiredNotificationsEnabled = false - @AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false +@MainActor final class PushNotificationSettingsViewModel: ObservableObject { + @Published var isLoading = false + @Published var isLoadingRules = true - func checkPushNotificationsStatus() { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled - } + @Published var emails = [NewsletterEmail]() + @Published var desiredNotificationsEnabled = false + @Published var allSubscriptionsNotificationRule: Rule? + @Published var hasSubscriptionsNotifyRule = false + @Published var showOperationToast = false + @Published var operationStatus: OperationStatus = .none + @Published var operationMessage: String? + + let subscriptionRuleName = "system.autoNotify.subscriptions" + + @AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false + + func checkPushNotificationsStatus() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled } } + } - func tryUpdateToDesired(dataService: DataService) { - UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue) + func tryUpdateToDesired(dataService: DataService) { + UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue) - if desiredNotificationsEnabled { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in - DispatchQueue.main.async { - self.desiredNotificationsEnabled = granted - Task { - if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) { - _ = try? await dataService.syncDeviceToken( - deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken)) - } - NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil) + if desiredNotificationsEnabled { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in + DispatchQueue.main.async { + self.desiredNotificationsEnabled = granted + Task { + if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) { + _ = try? await dataService.syncDeviceToken( + deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken)) } + NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil) } } - } else { - if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) { - Task { - try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID)) - } + } + } else { + if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) { + Task { + try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID)) } } } } + func loadRule(dataService: DataService) async { + do { + let rule = try await dataService.rules().filter { $0.name == subscriptionRuleName }.first + setAllSubscriptionRule(rule: rule) + } catch { + print("error fetching", error) + setAllSubscriptionRule(rule: nil) + } + } + + func setAllSubscriptionRule(rule: Rule?) { + allSubscriptionsNotificationRule = rule + hasSubscriptionsNotifyRule = allSubscriptionsNotificationRule != nil + isLoadingRules = false + } + + func createSubscriptionNotificationRule(dataService: DataService) { + if allSubscriptionsNotificationRule != nil { + return + } + + operationMessage = "Creating notification rule..." + operationStatus = .isPerforming + showOperationToast = true + + Task { + do { + let rule = try await dataService.createNotificationRule( + name: subscriptionRuleName, + filter: "in:all has:subscription" + ) + setAllSubscriptionRule(rule: rule) + operationMessage = "Rule created" + operationStatus = .success + } catch { + print("error creating notification rule: ", error) + operationMessage = "Failed to create notification rule" + operationStatus = .failure + } + } + } + + func deleteSubscriptionNotificationRule(dataService: DataService) { + operationMessage = "Creating label rule..." + operationStatus = .isPerforming + showOperationToast = true + + Task { + do { + if let allSubscriptionsNotificationRule = allSubscriptionsNotificationRule { + _ = try await dataService.deleteRule(ruleID: allSubscriptionsNotificationRule.id) + setAllSubscriptionRule(rule: nil) + operationMessage = "Notification rule deleted" + operationStatus = .success + } + } catch { + operationMessage = "Failed to create label rule" + operationStatus = .failure + } + } + } +} + struct PushNotificationSettingsView: View { @EnvironmentObject var dataService: DataService @Environment(\.dismiss) private var dismiss @@ -54,6 +127,12 @@ var body: some View { Group { + WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) { + OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus) + } label: { + EmptyView() + }.buttonStyle(.plain) + #if os(iOS) Form { innerBody @@ -68,7 +147,10 @@ .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in dismiss() } - .task { viewModel.checkPushNotificationsStatus() } + .task { + viewModel.checkPushNotificationsStatus() + await viewModel.loadRule(dataService: dataService) + } } private var notificationsText: some View { @@ -84,6 +166,14 @@ .accentColor(.blue) } + private var rulesSection: some View { + if viewModel.isLoadingRules { + AnyView(EmptyView()) + } else { + AnyView(Toggle("Notify me when new items arrive from my subscriptions", isOn: $viewModel.hasSubscriptionsNotifyRule)) + } + } + private var innerBody: some View { Group { Section { @@ -96,12 +186,35 @@ notificationsText } + Section { + rulesSection + } + Section { NavigationLink("Devices") { PushNotificationDevicesView() } } } + .onChange(of: viewModel.hasSubscriptionsNotifyRule) { newValue in + print("has notification rule: \(newValue)") + if viewModel.isLoadingRules { + return + } + + if newValue { + viewModel.createSubscriptionNotificationRule(dataService: dataService) + } else { + viewModel.deleteSubscriptionNotificationRule(dataService: dataService) + } + } + .onChange(of: viewModel.operationStatus) { newValue in + if newValue == .success || newValue == .failure { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) { + viewModel.showOperationToast = false + } + } + } .navigationTitle(LocalText.pushNotificationsGeneric) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift index 0f6507bfc..197cbee59 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift @@ -273,7 +273,7 @@ struct SubscriptionsView: View { await viewModel.cancelSubscription(dataService: dataService, subscription: subscription) } } - ) + ).background(Color.systemBackground) } label: { SubscriptionCell(subscription: subscription) } @@ -526,6 +526,25 @@ struct SubscriptionSettingsView: View { } } } +// +// var notificationRuleRow: some View { +// HStack { +// Text("Add Labels") +// Spacer() +// if isLoadingRule || viewModel.rules != nil { +// Button(action: { showLabelsSelector = true }, label: { +// if let ruleLabels = ruleLabels { +// let labelNames = ruleLabels.map(\.unwrappedName) +// Text("[\(labelNames.joined(separator: ","))]") +// } else { +// Text("Create Rule") +// } +// }).tint(Color.blue) +// } else { +// ProgressView() +// } +// } +// } var body: some View { VStack { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift index 48dfc16f5..a59fd43e8 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift @@ -89,4 +89,51 @@ public extension DataService { } } } + + func createNotificationRule(name: String, filter: String) async throws -> Rule { + enum MutationResult { + case result(rule: Rule) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + setRuleError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + setRuleSuccess: .init { .result(rule: try $0.rule(selection: ruleSelection)) } + ) + } + + let mutation = Selection.Mutation { + try $0.setRule( + input: InputObjects.SetRuleInput( + actions: [InputObjects.RuleActionInput(params: [], type: .sendNotification)], + enabled: true, + eventTypes: [.pageCreated], + filter: filter, + id: OptionalArgument(nil), + name: name + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .result(rule: rule): + continuation.resume(returning: rule) + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } } diff --git a/apple/Sources/PushNotificationConfig.swift b/apple/Sources/PushNotificationConfig.swift index 71ec6cbf0..d6588b779 100644 --- a/apple/Sources/PushNotificationConfig.swift +++ b/apple/Sources/PushNotificationConfig.swift @@ -55,7 +55,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let userInfo = notification.request.content.userInfo UIApplication.shared.applicationIconBadgeNumber = 0 - print(userInfo) // extract data sent along with PN + print("push data", userInfo) // extract data sent along with PN completionHandler([[.banner, .sound]]) } diff --git a/packages/api/src/jobs/trigger_rule.ts b/packages/api/src/jobs/trigger_rule.ts index 354fd3d82..813a67526 100644 --- a/packages/api/src/jobs/trigger_rule.ts +++ b/packages/api/src/jobs/trigger_rule.ts @@ -70,8 +70,10 @@ const sendNotification = async (obj: RuleActionObj) => { const message = { title: item.author || item.siteName || 'Omnivore', body: item.title, + image: item.thumbnail, + } + const data = { libraryItemId: item.id, - thumbnail: item.thumbnail, } return sendPushNotifications(obj.userId, message, 'rule')