From 5c32c24ae593e0fcfa24bdf0841133965d3d1bf0 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 26 Mar 2024 15:49:43 +0100 Subject: [PATCH] Add supports for notifications filter API --- IceCubesApp/App/AppRegistry.swift | 7 + IceCubesApp/App/Tabs/NavigationSheet.swift | 8 +- .../Localization/Localizable.xcstrings | 835 +++++++++++++++++- .../List/ConversationsListView.swift | 4 +- .../ToolbarItem/CloseToolbarItem.swift | 18 + .../DesignSystem/Views/ErrorView.swift | 8 +- .../Env/Sources/Env/CurrentInstance.swift | 4 + Packages/Env/Sources/Env/Router.swift | 2 + .../Sources/Models/NotificationsPolicy.swift | 14 + .../Sources/Models/NotificationsRequest.swift | 7 + .../Network/Endpoint/Notifications.swift | 30 +- .../NotificationsHeaderFilteredView.swift | 39 + .../Notifications/NotificationsListView.swift | 35 +- .../NotificationsPolicyView.swift | 90 ++ .../NotificationsViewModel.swift | 42 +- .../NotificationsRequestsListView.swift | 92 ++ .../NotificationsRequestsRowView.swift | 56 ++ .../StatusKit/Detail/StatusDetailView.swift | 4 +- .../StatusKit/List/StatusesListView.swift | 4 +- 19 files changed, 1260 insertions(+), 39 deletions(-) create mode 100644 Packages/DesignSystem/Sources/DesignSystem/ToolbarItem/CloseToolbarItem.swift create mode 100644 Packages/Models/Sources/Models/NotificationsPolicy.swift create mode 100644 Packages/Models/Sources/Models/NotificationsRequest.swift create mode 100644 Packages/Notifications/Sources/Notifications/NotificationsHeaderFilteredView.swift create mode 100644 Packages/Notifications/Sources/Notifications/NotificationsPolicyView.swift create mode 100644 Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsListView.swift create mode 100644 Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsRowView.swift diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index d69707ca..766e5045 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -11,6 +11,7 @@ import Models import StatusKit import SwiftUI import Timeline +import Notifications @MainActor extension View { @@ -63,6 +64,12 @@ extension View { TrendingLinksListView(cards: cards) case let .tagsList(tags): TagsListView(tags: tags) + case .notificationsRequests: + NotificationsRequestsListView() + case let .notificationForAccount(accountId): + NotificationsListView(lockedType: nil , + lockedAccountId: accountId, + scrollToTopSignal: .constant(0)) } } } diff --git a/IceCubesApp/App/Tabs/NavigationSheet.swift b/IceCubesApp/App/Tabs/NavigationSheet.swift index a102145b..c0812234 100644 --- a/IceCubesApp/App/Tabs/NavigationSheet.swift +++ b/IceCubesApp/App/Tabs/NavigationSheet.swift @@ -17,13 +17,7 @@ struct NavigationSheet: View { NavigationStack { content() .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle") - } - } + CloseToolbarItem() } } } diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index 74908b3b..593b3ee3 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -34599,6 +34599,839 @@ } } }, + "notifications.content-filter.newAccounts" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New accounts" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "New accounts" + } + } + } + }, + "notifications.content-filter.peopleNotFollowingYou" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "People not following you" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People not following you" + } + } + } + }, + "notifications.content-filter.peopleYouDontFollow" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "People you don't follow" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "People you don't follow" + } + } + } + }, + "notifications.content-filter.privateMentions" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unsolicited private mentions" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unsolicited private mentions" + } + } + } + }, + "notifications.content-filter.requests.title" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtered notifications" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtered notifications" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filtered notifications" + } + } + } + }, + "notifications.content-filter.title" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications Filter" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notifications Filter" + } + } + } + }, + "notifications.content-filter.title-inline" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter out notifications from…" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Filter out notifications from…" + } + } + } + }, "notifications.empty.message" : { "comment" : "MARK: Package: Notifications", "extractionState" : "manual", @@ -38197,7 +39030,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Toutes les notifications" + "value" : "Notifications" } }, "it" : { diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift index a94e508d..bffc0460 100644 --- a/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift @@ -56,9 +56,7 @@ public struct ConversationsListView: View { message: "conversations.error.message", buttonTitle: "conversations.error.button") { - Task { - await viewModel.fetchConversations() - } + await viewModel.fetchConversations() } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/ToolbarItem/CloseToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/ToolbarItem/CloseToolbarItem.swift new file mode 100644 index 00000000..7009710c --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/ToolbarItem/CloseToolbarItem.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public struct CloseToolbarItem: ToolbarContent { + @Environment(\.dismiss) private var dismiss + + public init() {} + + public var body: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + dismiss() + }, label: { + Image(systemName: "xmark.circle") + }) + .keyboardShortcut(.cancelAction) + } + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/ErrorView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/ErrorView.swift index 80f06d16..f2d87fc3 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/ErrorView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/ErrorView.swift @@ -4,9 +4,9 @@ public struct ErrorView: View { public let title: LocalizedStringKey public let message: LocalizedStringKey public let buttonTitle: LocalizedStringKey - public let onButtonPress: () -> Void + public let onButtonPress: (() async -> Void) - public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() -> Void)) { + public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() async -> Void) ) { self.title = title self.message = message self.buttonTitle = buttonTitle @@ -29,7 +29,9 @@ public struct ErrorView: View { .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button { - onButtonPress() + Task { + await onButtonPress() + } } label: { Text(buttonTitle) } diff --git a/Packages/Env/Sources/Env/CurrentInstance.swift b/Packages/Env/Sources/Env/CurrentInstance.swift index 41766cbe..1f1a63ef 100644 --- a/Packages/Env/Sources/Env/CurrentInstance.swift +++ b/Packages/Env/Sources/Env/CurrentInstance.swift @@ -34,6 +34,10 @@ import Observation public var isEditAltTextSupported: Bool { version >= 4.1 } + + public var isNotificationsFilterSupported: Bool { + version >= 4.3 + } private init() {} diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index 75e5e49e..2c282ae0 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -23,6 +23,8 @@ public enum RouterDestination: Hashable { case trendingTimeline case trendingLinks(cards: [Card]) case tagsList(tags: [Tag]) + case notificationsRequests + case notificationForAccount(accountId: String) } public enum WindowDestinationEditor: Hashable, Codable { diff --git a/Packages/Models/Sources/Models/NotificationsPolicy.swift b/Packages/Models/Sources/Models/NotificationsPolicy.swift new file mode 100644 index 00000000..d87942cd --- /dev/null +++ b/Packages/Models/Sources/Models/NotificationsPolicy.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct NotificationsPolicy: Codable, Sendable { + public var filterNotFollowing: Bool + public var filterNotFollowers: Bool + public var filterNewAccounts: Bool + public var filterPrivateMentions: Bool + public let summary: Summary + + public struct Summary: Codable, Sendable { + public let pendingRequestsCount: String + public let pendingNotificationsCount: String + } +} diff --git a/Packages/Models/Sources/Models/NotificationsRequest.swift b/Packages/Models/Sources/Models/NotificationsRequest.swift new file mode 100644 index 00000000..c38deb0e --- /dev/null +++ b/Packages/Models/Sources/Models/NotificationsRequest.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct NotificationsRequest: Identifiable, Decodable, Sendable { + public let id: String + public let account: Account + public let notificationsCount: String +} diff --git a/Packages/Network/Sources/Network/Endpoint/Notifications.swift b/Packages/Network/Sources/Network/Endpoint/Notifications.swift index 701061d7..85b7d332 100644 --- a/Packages/Network/Sources/Network/Endpoint/Notifications.swift +++ b/Packages/Network/Sources/Network/Endpoint/Notifications.swift @@ -1,26 +1,54 @@ import Foundation +import Models public enum Notifications: Endpoint { case notifications(minId: String?, maxId: String?, types: [String]?, limit: Int) + case notificationsForAccount(accountId: String, maxId: String?) case notification(id: String) + case policy + case putPolicy(policy: Models.NotificationsPolicy) + case requests + case acceptRequest(id: String) + case dismissRequest(id: String) case clear public func path() -> String { switch self { - case .notifications: + case .notifications, .notificationsForAccount: "notifications" case let .notification(id): "notifications/\(id)" + case .policy, .putPolicy: + "notifications/policy" + case .requests: + "notifications/requests" + case let .acceptRequest(id): + "notifications/requests/\(id)/accept" + case let .dismissRequest(id): + "notifications/requests/\(id)/dismiss" case .clear: "notifications/clear" } } + + public var jsonValue: (any Encodable)? { + switch self { + case let .putPolicy(policy): + return policy + default: + return nil + } + } public func queryItems() -> [URLQueryItem]? { switch self { + case let .notificationsForAccount(accountId, maxId): + var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) ?? [] + params.append(.init(name: "account_id", value: accountId)) + return params case let .notifications(mindId, maxId, types, limit): var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: mindId) ?? [] params.append(.init(name: "limit", value: String(limit))) diff --git a/Packages/Notifications/Sources/Notifications/NotificationsHeaderFilteredView.swift b/Packages/Notifications/Sources/Notifications/NotificationsHeaderFilteredView.swift new file mode 100644 index 00000000..84904272 --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/NotificationsHeaderFilteredView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Models +import DesignSystem +import Env + +struct NotificationsHeaderFilteredView: View { + @Environment(Theme.self) private var theme + @Environment(RouterPath.self) private var routerPath + + let filteredNotifications: NotificationsPolicy.Summary + + var body: some View { + if let count = Int(filteredNotifications.pendingNotificationsCount), count > 0 { + HStack { + Label("notifications.content-filter.requests.title", systemImage: "archivebox") + .foregroundStyle(.secondary) + Spacer() + Text(filteredNotifications.pendingNotificationsCount) + .font(.footnote) + .fontWeight(.semibold) + .monospacedDigit() + .foregroundStyle(theme.primaryBackgroundColor) + .padding(8) + .background(.secondary) + .clipShape(Circle()) + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .onTapGesture { + routerPath.navigate(to: .notificationsRequests) + } + .listRowBackground(theme.secondaryBackgroundColor) + .listRowInsets(.init(top: 12, + leading: .layoutPadding, + bottom: 12, + trailing: .layoutPadding)) + } + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift index 7a231554..49c3f2d4 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift @@ -7,18 +7,26 @@ import SwiftUI @MainActor public struct NotificationsListView: View { @Environment(\.scenePhase) private var scenePhase + @Environment(Theme.self) private var theme @Environment(StreamWatcher.self) private var watcher @Environment(Client.self) private var client @Environment(RouterPath.self) private var routerPath @Environment(CurrentAccount.self) private var account + @Environment(CurrentInstance.self) private var currentInstance + @State private var viewModel = NotificationsViewModel() + @State private var isNotificationsPolicyPresented: Bool = false @Binding var scrollToTopSignal: Int let lockedType: Models.Notification.NotificationType? + let lockedAccountId: String? - public init(lockedType: Models.Notification.NotificationType?, scrollToTopSignal: Binding) { + public init(lockedType: Models.Notification.NotificationType? = nil, + lockedAccountId: String? = nil, + scrollToTopSignal: Binding) { self.lockedType = lockedType + self.lockedAccountId = lockedAccountId _scrollToTopSignal = scrollToTopSignal } @@ -27,6 +35,9 @@ public struct NotificationsListView: View { List { scrollToTopView topPaddingView + if lockedAccountId == nil, let summary = viewModel.policy?.summary { + NotificationsHeaderFilteredView(filteredNotifications: summary) + } notificationsView } .id(account.account?.id) @@ -58,7 +69,7 @@ public struct NotificationsListView: View { } } .toolbar { - if lockedType == nil { + if lockedType == nil && lockedAccountId == nil { ToolbarTitleMenu { Button { viewModel.selectedType = nil @@ -83,25 +94,39 @@ public struct NotificationsListView: View { } } } + if currentInstance.isNotificationsFilterSupported { + Divider() + Button { + isNotificationsPolicyPresented = true + } label: { + Label("notifications.content-filter.title", systemImage: "line.3.horizontal.decrease") + } + } } } } + .sheet(isPresented: $isNotificationsPolicyPresented) { + NotificationsPolicyView() + } .navigationBarTitleDisplayMode(.inline) #if !os(visionOS) .scrollContentBackground(.hidden) .background(theme.primaryBackgroundColor) #endif - .onAppear { + .onAppear { viewModel.client = client viewModel.currentAccount = account if let lockedType { viewModel.isLockedType = true viewModel.selectedType = lockedType + } else if let lockedAccountId { + viewModel.lockedAccountId = lockedAccountId } else { viewModel.loadSelectedType() } Task { await viewModel.fetchNotifications() + await viewModel.fetchPolicy() } } .refreshable { @@ -203,9 +228,7 @@ public struct NotificationsListView: View { message: "notifications.error.message", buttonTitle: "action.retry") { - Task { - await viewModel.fetchNotifications() - } + await viewModel.fetchNotifications() } #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) diff --git a/Packages/Notifications/Sources/Notifications/NotificationsPolicyView.swift b/Packages/Notifications/Sources/Notifications/NotificationsPolicyView.swift new file mode 100644 index 00000000..c9c61d79 --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/NotificationsPolicyView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import Network +import DesignSystem +import Models + +@MainActor +struct NotificationsPolicyView: View { + @Environment(\.dismiss) private var dismiss + + @Environment(Client.self) private var client + @Environment(Theme.self) private var theme + + @State private var policy: NotificationsPolicy? + @State private var isUpdating: Bool = false + + var body: some View { + NavigationStack { + Form { + Section("notifications.content-filter.title-inline") { + Toggle(isOn: .init(get: { policy?.filterNotFollowing == true }, + set: { newValue in + policy?.filterNotFollowing = newValue + Task { await updatePolicy() } + }), label: { + Text("notifications.content-filter.peopleYouDontFollow") + }) + Toggle(isOn: .init(get: { policy?.filterNotFollowers == true }, + set: { newValue in + policy?.filterNotFollowers = newValue + Task { await updatePolicy() } + }), label: { + Text("notifications.content-filter.peopleNotFollowingYou") + }) + Toggle(isOn: .init(get: { policy?.filterNewAccounts == true }, + set: { newValue in + policy?.filterNewAccounts = newValue + Task { await updatePolicy() } + }), label: { + Text("notifications.content-filter.newAccounts") + }) + Toggle(isOn: .init(get: { policy?.filterPrivateMentions == true }, + set: { newValue in + policy?.filterPrivateMentions = newValue + Task { await updatePolicy() } + }), label: { + Text("notifications.content-filter.privateMentions") + }) + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + } + .formStyle(.grouped) + .navigationTitle("notifications.content-filter.title") + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .toolbar { CloseToolbarItem() } + .disabled(policy == nil || isUpdating) + .task { + await getPolicy() + } + } + .presentationDetents([.medium]) + .presentationBackground(.thinMaterial) + } + + private func getPolicy() async { + defer { + isUpdating = false + } + do { + isUpdating = true + policy = try await client.get(endpoint: Notifications.policy) + } catch { + dismiss() + } + } + + private func updatePolicy() async { + if let policy { + defer { + isUpdating = false + } + do { + isUpdating = true + self.policy = try await client.put(endpoint: Notifications.putPolicy(policy: policy)) + } catch { } + } + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift index a8c1f971..669e8a15 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift @@ -39,6 +39,8 @@ import SwiftUI private let filterKey = "notification-filter" var state: State = .loading var isLockedType: Bool = false + var lockedAccountId: String? = nil + var policy: Models.NotificationsPolicy? var selectedType: Models.Notification.NotificationType? { didSet { guard oldValue != selectedType, @@ -82,11 +84,16 @@ import SwiftUI var nextPageState: State.PagingState = .hasNextPage if consolidatedNotifications.isEmpty { state = .loading - let notifications: [Models.Notification] = - try await client.get(endpoint: Notifications.notifications(minId: nil, - maxId: nil, - types: queryTypes, - limit: Constants.notificationLimit)) + let notifications: [Models.Notification] + if let lockedAccountId { + notifications = try await client.get(endpoint: Notifications.notificationsForAccount(accountId: lockedAccountId, + maxId: nil)) + } else { + notifications = try await client.get(endpoint: Notifications.notifications(minId: nil, + maxId: nil, + types: queryTypes, + limit: Constants.notificationLimit)) + } consolidatedNotifications = await notifications.consolidated(selectedType: selectedType) markAsRead() nextPageState = notifications.count < Constants.notificationLimit ? .none : .hasNextPage @@ -119,7 +126,7 @@ import SwiftUI } private func fetchNewPages(minId: String, maxPages: Int) async -> [Models.Notification] { - guard let client else { return [] } + guard let client, lockedAccountId == nil else { return [] } var pagesLoaded = 0 var allNotifications: [Models.Notification] = [] var latestMinId = minId @@ -146,11 +153,17 @@ import SwiftUI func fetchNextPage() async throws { guard let client else { return } guard let lastId = consolidatedNotifications.last?.notificationIds.last else { return } - let newNotifications: [Models.Notification] = - try await client.get(endpoint: Notifications.notifications(minId: nil, - maxId: lastId, - types: queryTypes, - limit: Constants.notificationLimit)) + let newNotifications: [Models.Notification] + if let lockedAccountId { + newNotifications = + try await client.get(endpoint: Notifications.notificationsForAccount(accountId: lockedAccountId, maxId: lastId)) + } else { + newNotifications = + try await client.get(endpoint: Notifications.notifications(minId: nil, + maxId: lastId, + types: queryTypes, + limit: Constants.notificationLimit)) + } await consolidatedNotifications.append(contentsOf: newNotifications.consolidated(selectedType: selectedType)) if consolidatedNotifications.contains(where: { $0.type == .follow_request }) { await currentAccount?.fetchFollowerRequests() @@ -167,13 +180,18 @@ import SwiftUI } catch {} } } + + func fetchPolicy() async { + policy = try? await client?.get(endpoint: Notifications.policy) + } func handleEvent(event: any StreamEvent) { Task { // Check if the event is a notification, // if it is not already in the list, // and if it can be shown (no selected type or the same as the received notification type) - if let event = event as? StreamEventNotification, + if lockedAccountId == nil, + let event = event as? StreamEventNotification, !consolidatedNotifications.flatMap(\.notificationIds).contains(event.notification.id), selectedType == nil || selectedType?.rawValue == event.notification.type { diff --git a/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsListView.swift b/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsListView.swift new file mode 100644 index 00000000..6eb91f5b --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsListView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import Network +import Models +import DesignSystem + +@MainActor +public struct NotificationsRequestsListView: View { + @Environment(Client.self) private var client + @Environment(Theme.self) private var theme + + enum ViewState { + case loading + case error + case requests(_ data: [NotificationsRequest]) + } + @State private var viewState: ViewState = .loading + + public init() { } + + public var body: some View { + List { + switch viewState { + case .loading: + ProgressView() + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + .listSectionSeparator(.hidden) + case .error: + ErrorView(title: "notifications.error.title", + message: "notifications.error.message", + buttonTitle: "action.retry") + { + await fetchRequests() + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + .listSectionSeparator(.hidden) + case let .requests(data): + ForEach(data) { request in + NotificationsRequestsRowView(request: request) + .swipeActions { + Button { + Task { await acceptRequest(request) } + } label: { + Label("account.follow-request.accept", systemImage: "checkmark") + } + + Button { + Task { await dismissRequest(request) } + } label: { + Label("account.follow-request.reject", systemImage: "xmark") + } + .tint(.red) + } + } + } + } + .listStyle(.plain) + #if !os(visionOS) + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) + #endif + .navigationTitle("notifications.content-filter.requests.title") + .navigationBarTitleDisplayMode(.inline) + .task { + await fetchRequests() + } + .refreshable { + await fetchRequests() + } + } + + private func fetchRequests() async { + do { + viewState = .requests(try await client.get(endpoint: Notifications.requests)) + } catch { + viewState = .error + } + } + + private func acceptRequest(_ request: NotificationsRequest) async { + _ = try? await client.post(endpoint: Notifications.acceptRequest(id: request.id)) + await fetchRequests() + } + + private func dismissRequest(_ request: NotificationsRequest) async { + _ = try? await client.post(endpoint: Notifications.dismissRequest(id: request.id)) + await fetchRequests() + } +} diff --git a/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsRowView.swift b/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsRowView.swift new file mode 100644 index 00000000..424668ca --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/Requests/NotificationsRequestsRowView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Models +import DesignSystem +import Env +import Network + +struct NotificationsRequestsRowView: View { + @Environment(Theme.self) private var theme + @Environment(RouterPath.self) private var routerPath + @Environment(Client.self) private var client + + let request: NotificationsRequest + + var body: some View { + HStack(alignment: .center, spacing: 8) { + AvatarView(request.account.avatar, config: .embed) + + VStack(alignment: .leading) { + EmojiTextApp(request.account.cachedDisplayName, emojis: request.account.emojis) + .font(.scaledBody) + .foregroundStyle(theme.labelColor) + .lineLimit(1) + Text(request.account.acct) + .font(.scaledFootnote) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 4) + Spacer() + Text(request.notificationsCount) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(theme.primaryBackgroundColor) + .padding(8) + .background(.secondary) + .clipShape(Circle()) + + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .onTapGesture { + routerPath.navigate(to: .notificationForAccount(accountId: request.account.id)) + } + .listRowInsets(.init(top: 12, + leading: .layoutPadding, + bottom: 12, + trailing: .layoutPadding)) + #if os(visionOS) + .listRowBackground(RoundedRectangle(cornerRadius: 8) + .foregroundStyle(.background)) + #else + .listRowBackground(theme.primaryBackgroundColor) + #endif + } +} diff --git a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift index 69fc78da..2d1c297f 100644 --- a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift @@ -152,9 +152,7 @@ public struct StatusDetailView: View { message: "status.error.message", buttonTitle: "action.retry") { - Task { - await viewModel.fetch() - } + _ = await viewModel.fetch() } #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) diff --git a/Packages/StatusKit/Sources/StatusKit/List/StatusesListView.swift b/Packages/StatusKit/Sources/StatusKit/List/StatusesListView.swift index abe77e18..43f3c368 100644 --- a/Packages/StatusKit/Sources/StatusKit/List/StatusesListView.swift +++ b/Packages/StatusKit/Sources/StatusKit/List/StatusesListView.swift @@ -38,9 +38,7 @@ public struct StatusesListView: View where Fetcher: StatusesFetcher { message: "status.error.loading.message", buttonTitle: "action.retry") { - Task { - await fetcher.fetchNewestStatuses(pullToRefresh: false) - } + await fetcher.fetchNewestStatuses(pullToRefresh: false) } .listRowBackground(theme.primaryBackgroundColor) .listRowSeparator(.hidden)