Add supports for notifications filter API

This commit is contained in:
Thomas Ricouard 2024-03-26 15:49:43 +01:00
parent bb56047ee2
commit 5c32c24ae5
19 changed files with 1260 additions and 39 deletions

View file

@ -11,6 +11,7 @@ import Models
import StatusKit import StatusKit
import SwiftUI import SwiftUI
import Timeline import Timeline
import Notifications
@MainActor @MainActor
extension View { extension View {
@ -63,6 +64,12 @@ extension View {
TrendingLinksListView(cards: cards) TrendingLinksListView(cards: cards)
case let .tagsList(tags): case let .tagsList(tags):
TagsListView(tags: tags) TagsListView(tags: tags)
case .notificationsRequests:
NotificationsRequestsListView()
case let .notificationForAccount(accountId):
NotificationsListView(lockedType: nil ,
lockedAccountId: accountId,
scrollToTopSignal: .constant(0))
} }
} }
} }

View file

@ -17,13 +17,7 @@ struct NavigationSheet<Content: View>: View {
NavigationStack { NavigationStack {
content() content()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { CloseToolbarItem()
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle")
}
}
} }
} }
} }

View file

@ -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" : { "notifications.empty.message" : {
"comment" : "MARK: Package: Notifications", "comment" : "MARK: Package: Notifications",
"extractionState" : "manual", "extractionState" : "manual",
@ -38197,7 +39030,7 @@
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Toutes les notifications" "value" : "Notifications"
} }
}, },
"it" : { "it" : {

View file

@ -56,11 +56,9 @@ public struct ConversationsListView: View {
message: "conversations.error.message", message: "conversations.error.message",
buttonTitle: "conversations.error.button") buttonTitle: "conversations.error.button")
{ {
Task {
await viewModel.fetchConversations() await viewModel.fetchConversations()
} }
} }
}
if viewModel.nextPage != nil { if viewModel.nextPage != nil {
HStack { HStack {

View file

@ -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)
}
}
}

View file

@ -4,9 +4,9 @@ public struct ErrorView: View {
public let title: LocalizedStringKey public let title: LocalizedStringKey
public let message: LocalizedStringKey public let message: LocalizedStringKey
public let buttonTitle: 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.title = title
self.message = message self.message = message
self.buttonTitle = buttonTitle self.buttonTitle = buttonTitle
@ -29,7 +29,9 @@ public struct ErrorView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button { Button {
onButtonPress() Task {
await onButtonPress()
}
} label: { } label: {
Text(buttonTitle) Text(buttonTitle)
} }

View file

@ -35,6 +35,10 @@ import Observation
version >= 4.1 version >= 4.1
} }
public var isNotificationsFilterSupported: Bool {
version >= 4.3
}
private init() {} private init() {}
public func setClient(client: Client) { public func setClient(client: Client) {

View file

@ -23,6 +23,8 @@ public enum RouterDestination: Hashable {
case trendingTimeline case trendingTimeline
case trendingLinks(cards: [Card]) case trendingLinks(cards: [Card])
case tagsList(tags: [Tag]) case tagsList(tags: [Tag])
case notificationsRequests
case notificationForAccount(accountId: String)
} }
public enum WindowDestinationEditor: Hashable, Codable { public enum WindowDestinationEditor: Hashable, Codable {

View file

@ -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
}
}

View file

@ -0,0 +1,7 @@
import Foundation
public struct NotificationsRequest: Identifiable, Decodable, Sendable {
public let id: String
public let account: Account
public let notificationsCount: String
}

View file

@ -1,26 +1,54 @@
import Foundation import Foundation
import Models
public enum Notifications: Endpoint { public enum Notifications: Endpoint {
case notifications(minId: String?, case notifications(minId: String?,
maxId: String?, maxId: String?,
types: [String]?, types: [String]?,
limit: Int) limit: Int)
case notificationsForAccount(accountId: String, maxId: String?)
case notification(id: String) case notification(id: String)
case policy
case putPolicy(policy: Models.NotificationsPolicy)
case requests
case acceptRequest(id: String)
case dismissRequest(id: String)
case clear case clear
public func path() -> String { public func path() -> String {
switch self { switch self {
case .notifications: case .notifications, .notificationsForAccount:
"notifications" "notifications"
case let .notification(id): case let .notification(id):
"notifications/\(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: case .clear:
"notifications/clear" "notifications/clear"
} }
} }
public var jsonValue: (any Encodable)? {
switch self {
case let .putPolicy(policy):
return policy
default:
return nil
}
}
public func queryItems() -> [URLQueryItem]? { public func queryItems() -> [URLQueryItem]? {
switch self { 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): case let .notifications(mindId, maxId, types, limit):
var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: mindId) ?? [] var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: mindId) ?? []
params.append(.init(name: "limit", value: String(limit))) params.append(.init(name: "limit", value: String(limit)))

View file

@ -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))
}
}
}

View file

@ -7,18 +7,26 @@ import SwiftUI
@MainActor @MainActor
public struct NotificationsListView: View { public struct NotificationsListView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@Environment(StreamWatcher.self) private var watcher @Environment(StreamWatcher.self) private var watcher
@Environment(Client.self) private var client @Environment(Client.self) private var client
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
@Environment(CurrentAccount.self) private var account @Environment(CurrentAccount.self) private var account
@Environment(CurrentInstance.self) private var currentInstance
@State private var viewModel = NotificationsViewModel() @State private var viewModel = NotificationsViewModel()
@State private var isNotificationsPolicyPresented: Bool = false
@Binding var scrollToTopSignal: Int @Binding var scrollToTopSignal: Int
let lockedType: Models.Notification.NotificationType? let lockedType: Models.Notification.NotificationType?
let lockedAccountId: String?
public init(lockedType: Models.Notification.NotificationType?, scrollToTopSignal: Binding<Int>) { public init(lockedType: Models.Notification.NotificationType? = nil,
lockedAccountId: String? = nil,
scrollToTopSignal: Binding<Int>) {
self.lockedType = lockedType self.lockedType = lockedType
self.lockedAccountId = lockedAccountId
_scrollToTopSignal = scrollToTopSignal _scrollToTopSignal = scrollToTopSignal
} }
@ -27,6 +35,9 @@ public struct NotificationsListView: View {
List { List {
scrollToTopView scrollToTopView
topPaddingView topPaddingView
if lockedAccountId == nil, let summary = viewModel.policy?.summary {
NotificationsHeaderFilteredView(filteredNotifications: summary)
}
notificationsView notificationsView
} }
.id(account.account?.id) .id(account.account?.id)
@ -58,7 +69,7 @@ public struct NotificationsListView: View {
} }
} }
.toolbar { .toolbar {
if lockedType == nil { if lockedType == nil && lockedAccountId == nil {
ToolbarTitleMenu { ToolbarTitleMenu {
Button { Button {
viewModel.selectedType = nil viewModel.selectedType = nil
@ -83,9 +94,20 @@ 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) .navigationBarTitleDisplayMode(.inline)
#if !os(visionOS) #if !os(visionOS)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
@ -97,11 +119,14 @@ public struct NotificationsListView: View {
if let lockedType { if let lockedType {
viewModel.isLockedType = true viewModel.isLockedType = true
viewModel.selectedType = lockedType viewModel.selectedType = lockedType
} else if let lockedAccountId {
viewModel.lockedAccountId = lockedAccountId
} else { } else {
viewModel.loadSelectedType() viewModel.loadSelectedType()
} }
Task { Task {
await viewModel.fetchNotifications() await viewModel.fetchNotifications()
await viewModel.fetchPolicy()
} }
} }
.refreshable { .refreshable {
@ -203,10 +228,8 @@ public struct NotificationsListView: View {
message: "notifications.error.message", message: "notifications.error.message",
buttonTitle: "action.retry") buttonTitle: "action.retry")
{ {
Task {
await viewModel.fetchNotifications() await viewModel.fetchNotifications()
} }
}
#if !os(visionOS) #if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif #endif

View file

@ -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 { }
}
}
}

View file

@ -39,6 +39,8 @@ import SwiftUI
private let filterKey = "notification-filter" private let filterKey = "notification-filter"
var state: State = .loading var state: State = .loading
var isLockedType: Bool = false var isLockedType: Bool = false
var lockedAccountId: String? = nil
var policy: Models.NotificationsPolicy?
var selectedType: Models.Notification.NotificationType? { var selectedType: Models.Notification.NotificationType? {
didSet { didSet {
guard oldValue != selectedType, guard oldValue != selectedType,
@ -82,11 +84,16 @@ import SwiftUI
var nextPageState: State.PagingState = .hasNextPage var nextPageState: State.PagingState = .hasNextPage
if consolidatedNotifications.isEmpty { if consolidatedNotifications.isEmpty {
state = .loading state = .loading
let notifications: [Models.Notification] = let notifications: [Models.Notification]
try await client.get(endpoint: Notifications.notifications(minId: nil, 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, maxId: nil,
types: queryTypes, types: queryTypes,
limit: Constants.notificationLimit)) limit: Constants.notificationLimit))
}
consolidatedNotifications = await notifications.consolidated(selectedType: selectedType) consolidatedNotifications = await notifications.consolidated(selectedType: selectedType)
markAsRead() markAsRead()
nextPageState = notifications.count < Constants.notificationLimit ? .none : .hasNextPage nextPageState = notifications.count < Constants.notificationLimit ? .none : .hasNextPage
@ -119,7 +126,7 @@ import SwiftUI
} }
private func fetchNewPages(minId: String, maxPages: Int) async -> [Models.Notification] { 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 pagesLoaded = 0
var allNotifications: [Models.Notification] = [] var allNotifications: [Models.Notification] = []
var latestMinId = minId var latestMinId = minId
@ -146,11 +153,17 @@ import SwiftUI
func fetchNextPage() async throws { func fetchNextPage() async throws {
guard let client else { return } guard let client else { return }
guard let lastId = consolidatedNotifications.last?.notificationIds.last else { return } guard let lastId = consolidatedNotifications.last?.notificationIds.last else { return }
let newNotifications: [Models.Notification] = 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, try await client.get(endpoint: Notifications.notifications(minId: nil,
maxId: lastId, maxId: lastId,
types: queryTypes, types: queryTypes,
limit: Constants.notificationLimit)) limit: Constants.notificationLimit))
}
await consolidatedNotifications.append(contentsOf: newNotifications.consolidated(selectedType: selectedType)) await consolidatedNotifications.append(contentsOf: newNotifications.consolidated(selectedType: selectedType))
if consolidatedNotifications.contains(where: { $0.type == .follow_request }) { if consolidatedNotifications.contains(where: { $0.type == .follow_request }) {
await currentAccount?.fetchFollowerRequests() await currentAccount?.fetchFollowerRequests()
@ -168,12 +181,17 @@ import SwiftUI
} }
} }
func fetchPolicy() async {
policy = try? await client?.get(endpoint: Notifications.policy)
}
func handleEvent(event: any StreamEvent) { func handleEvent(event: any StreamEvent) {
Task { Task {
// Check if the event is a notification, // Check if the event is a notification,
// if it is not already in the list, // 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) // 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), !consolidatedNotifications.flatMap(\.notificationIds).contains(event.notification.id),
selectedType == nil || selectedType?.rawValue == event.notification.type selectedType == nil || selectedType?.rawValue == event.notification.type
{ {

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -152,9 +152,7 @@ public struct StatusDetailView: View {
message: "status.error.message", message: "status.error.message",
buttonTitle: "action.retry") buttonTitle: "action.retry")
{ {
Task { _ = await viewModel.fetch()
await viewModel.fetch()
}
} }
#if !os(visionOS) #if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)

View file

@ -38,10 +38,8 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
message: "status.error.loading.message", message: "status.error.loading.message",
buttonTitle: "action.retry") buttonTitle: "action.retry")
{ {
Task {
await fetcher.fetchNewestStatuses(pullToRefresh: false) await fetcher.fetchNewestStatuses(pullToRefresh: false)
} }
}
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)