This commit is contained in:
Thomas Ricouard 2024-05-04 13:19:19 +02:00
parent ba4cc899f8
commit c3edabb183
25 changed files with 269 additions and 276 deletions

View file

@ -8,10 +8,10 @@ import LinkPresentation
import Lists import Lists
import MediaUI import MediaUI
import Models import Models
import Notifications
import StatusKit import StatusKit
import SwiftUI import SwiftUI
import Timeline import Timeline
import Notifications
@MainActor @MainActor
extension View { extension View {
@ -67,7 +67,7 @@ extension View {
case .notificationsRequests: case .notificationsRequests:
NotificationsRequestsListView() NotificationsRequestsListView()
case let .notificationForAccount(accountId): case let .notificationForAccount(accountId):
NotificationsListView(lockedType: nil , NotificationsListView(lockedType: nil,
lockedAccountId: accountId, lockedAccountId: accountId,
scrollToTopSignal: .constant(0)) scrollToTopSignal: .constant(0))
case .blockedAccounts: case .blockedAccounts:

View file

@ -1,8 +1,8 @@
import AppIntents
import Env import Env
import MediaUI import MediaUI
import StatusKit import StatusKit
import SwiftUI import SwiftUI
import AppIntents
extension IceCubesApp { extension IceCubesApp {
var appScene: some Scene { var appScene: some Scene {
@ -125,20 +125,21 @@ extension IceCubesApp {
.defaultSize(width: 1200, height: 1000) .defaultSize(width: 1200, height: 1000)
.windowResizability(.contentMinSize) .windowResizability(.contentMinSize)
} }
private func handleIntent(_ intent: any AppIntent) { private func handleIntent(_: any AppIntent) {
if let postIntent = appIntentService.handledIntent?.intent as? PostIntent { if let postIntent = appIntentService.handledIntent?.intent as? PostIntent {
#if os(visionOS) || os(macOS) #if os(visionOS) || os(macOS)
openWindow(value: WindowDestinationEditor.prefilledStatusEditor(text: postIntent.content ?? "", openWindow(value: WindowDestinationEditor.prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility)) visibility: userPreferences.postVisibility))
#else #else
appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "", appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility) visibility: userPreferences.postVisibility)
#endif #endif
} else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent { } else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent {
selectedTab = tabIntent.tab.toAppTab selectedTab = tabIntent.tab.toAppTab
} else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent, } else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent,
let urls = imageIntent.images?.compactMap({ $0.fileURL }) { let urls = imageIntent.images?.compactMap({ $0.fileURL })
{
appRouterPath.presentedSheet = .imageURL(urls: urls, appRouterPath.presentedSheet = .imageURL(urls: urls,
visibility: userPreferences.postVisibility) visibility: userPreferences.postVisibility)
} }

View file

@ -1,10 +1,10 @@
import Account import Account
import AppIntents
import DesignSystem import DesignSystem
import Explore import Explore
import Foundation import Foundation
import StatusKit import StatusKit
import SwiftUI import SwiftUI
import AppIntents
@MainActor @MainActor
enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable { enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable {

View file

@ -1,5 +1,5 @@
import SwiftUI
import AppIntents import AppIntents
import SwiftUI
@Observable @Observable
public class AppIntentService: @unchecked Sendable { public class AppIntentService: @unchecked Sendable {
@ -7,19 +7,19 @@ public class AppIntentService: @unchecked Sendable {
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent) -> Bool { static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
} }
let id: String let id: String
let intent: any AppIntent let intent: any AppIntent
init(intent: any AppIntent) { init(intent: any AppIntent) {
self.id = UUID().uuidString id = UUID().uuidString
self.intent = intent self.intent = intent
} }
} }
public static let shared = AppIntentService() public static let shared = AppIntentService()
var handledIntent: HandledIntent? var handledIntent: HandledIntent?
private init() { } private init() {}
} }

View file

@ -1,67 +1,63 @@
import Foundation
import AppIntents
import AppAccount import AppAccount
import Network import AppIntents
import Env import Env
import Foundation
import Models import Models
import Network
enum PostVisibility: String, AppEnum { enum PostVisibility: String, AppEnum {
case direct, priv, unlisted, pub case direct, priv, unlisted, pub
public static var caseDisplayRepresentations: [PostVisibility : DisplayRepresentation] { public static var caseDisplayRepresentations: [PostVisibility: DisplayRepresentation] {
[.direct: "Private", [.direct: "Private",
.priv: "Followers Only", .priv: "Followers Only",
.unlisted: "Quiet Public", .unlisted: "Quiet Public",
.pub: "Public"] .pub: "Public"]
} }
static var typeDisplayName: LocalizedStringResource { static var typeDisplayName: LocalizedStringResource { "Visibility" }
get { "Visibility" }
}
public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Visibility" public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Visibility"
var toAppVisibility: Models.Visibility { var toAppVisibility: Models.Visibility {
switch self { switch self {
case .direct: case .direct:
.direct .direct
case .priv: case .priv:
.priv .priv
case .unlisted: case .unlisted:
.unlisted .unlisted
case .pub: case .pub:
.pub .pub
} }
} }
} }
struct AppAccountWrapper: Identifiable, AppEntity { struct AppAccountWrapper: Identifiable, AppEntity {
var id: String { account.id } var id: String { account.id }
let account: AppAccount let account: AppAccount
static var defaultQuery = DefaultAppAccountQuery() static var defaultQuery = DefaultAppAccountQuery()
static var typeDisplayRepresentation: TypeDisplayRepresentation = "AppAccount" static var typeDisplayRepresentation: TypeDisplayRepresentation = "AppAccount"
var displayRepresentation: DisplayRepresentation { var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(account.accountName ?? account.server)") DisplayRepresentation(title: "\(account.accountName ?? account.server)")
} }
} }
struct DefaultAppAccountQuery: EntityQuery { struct DefaultAppAccountQuery: EntityQuery {
func entities(for identifiers: [AppAccountWrapper.ID]) async throws -> [AppAccountWrapper] { func entities(for identifiers: [AppAccountWrapper.ID]) async throws -> [AppAccountWrapper] {
return await AppAccountsManager.shared.availableAccounts.filter { account in return await AppAccountsManager.shared.availableAccounts.filter { account in
identifiers.contains { id in identifiers.contains { id in
id == account.id id == account.id
} }
}.map{ AppAccountWrapper(account: $0 )} }.map { AppAccountWrapper(account: $0) }
} }
func suggestedEntities() async throws -> [AppAccountWrapper] { func suggestedEntities() async throws -> [AppAccountWrapper] {
await AppAccountsManager.shared.availableAccounts.map{ .init(account: $0)} await AppAccountsManager.shared.availableAccounts.map { .init(account: $0) }
} }
func defaultResult() async -> AppAccountWrapper? { func defaultResult() async -> AppAccountWrapper? {
@ -72,21 +68,20 @@ struct DefaultAppAccountQuery: EntityQuery {
struct InlinePostIntent: AppIntent { struct InlinePostIntent: AppIntent {
static let title: LocalizedStringResource = "Send text status to Mastodon" static let title: LocalizedStringResource = "Send text status to Mastodon"
static var description: IntentDescription { static var description: IntentDescription {
get { "Send a text status to Mastodon using Ice Cubes"
"Send a text status to Mastodon using Ice Cubes"
}
} }
static let openAppWhenRun: Bool = false static let openAppWhenRun: Bool = false
@Parameter(title: "Account", requestValueDialog: IntentDialog("Account")) @Parameter(title: "Account", requestValueDialog: IntentDialog("Account"))
var account: AppAccountWrapper var account: AppAccountWrapper
@Parameter(title: "Post visibility", requestValueDialog: IntentDialog("Visibility of your post")) @Parameter(title: "Post visibility", requestValueDialog: IntentDialog("Visibility of your post"))
var visibility: PostVisibility var visibility: PostVisibility
@Parameter(title: "Post content", requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon")) @Parameter(title: "Post content", requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon"))
var content: String var content: String
@MainActor @MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView { func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
let client = Client(server: account.account.server, version: .v1, oauthToken: account.account.oauthToken) let client = Client(server: account.account.server, version: .v1, oauthToken: account.account.oauthToken)

View file

@ -1,21 +1,20 @@
import Foundation
import AppIntents import AppIntents
import Foundation
struct PostImageIntent: AppIntent { struct PostImageIntent: AppIntent {
static let title: LocalizedStringResource = "Post an image to Mastodon" static let title: LocalizedStringResource = "Post an image to Mastodon"
static var description: IntentDescription { static var description: IntentDescription {
get { "Use Ice Cubes to post a status with an image to Mastodon"
"Use Ice Cubes to post a status with an image to Mastodon"
}
} }
static let openAppWhenRun: Bool = true static let openAppWhenRun: Bool = true
@Parameter(title: "Image", @Parameter(title: "Image",
description: "Image to post on Mastodon", description: "Image to post on Mastodon",
supportedTypeIdentifiers: ["public.image"], supportedTypeIdentifiers: ["public.image"],
inputConnectionBehavior: .connectToPreviousIntentResult) inputConnectionBehavior: .connectToPreviousIntentResult)
var images: [IntentFile]? var images: [IntentFile]?
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
AppIntentService.shared.handledIntent = .init(intent: self) AppIntentService.shared.handledIntent = .init(intent: self)
return .result() return .result()

View file

@ -1,18 +1,17 @@
import Foundation
import AppIntents import AppIntents
import Foundation
struct PostIntent: AppIntent { struct PostIntent: AppIntent {
static let title: LocalizedStringResource = "Post status to Mastodon" static let title: LocalizedStringResource = "Post status to Mastodon"
static var description: IntentDescription { static var description: IntentDescription {
get { "Use Ice Cubes to post a status to Mastodon"
"Use Ice Cubes to post a status to Mastodon"
}
} }
static let openAppWhenRun: Bool = true static let openAppWhenRun: Bool = true
@Parameter(title: "Post content", inputConnectionBehavior: .connectToPreviousIntentResult) @Parameter(title: "Post content", inputConnectionBehavior: .connectToPreviousIntentResult)
var content: String? var content: String?
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
AppIntentService.shared.handledIntent = .init(intent: self) AppIntentService.shared.handledIntent = .init(intent: self)
return .result() return .result()

View file

@ -1,5 +1,5 @@
import Foundation
import AppIntents import AppIntents
import Foundation
enum TabEnum: String, AppEnum, Sendable { enum TabEnum: String, AppEnum, Sendable {
case timeline, notifications, mentions, explore, messages, settings case timeline, notifications, mentions, explore, messages, settings
@ -12,13 +12,11 @@ enum TabEnum: String, AppEnum, Sendable {
case lists case lists
case links case links
static var typeDisplayName: LocalizedStringResource { static var typeDisplayName: LocalizedStringResource { "Tab" }
get { "Tab" }
}
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Tab" static let typeDisplayRepresentation: TypeDisplayRepresentation = "Tab"
nonisolated static var caseDisplayRepresentations: [TabEnum : DisplayRepresentation] { nonisolated static var caseDisplayRepresentations: [TabEnum: DisplayRepresentation] {
[.timeline: .init(title: "Home Timeline"), [.timeline: .init(title: "Home Timeline"),
.trending: .init(title: "Trending Timeline"), .trending: .init(title: "Trending Timeline"),
.federated: .init(title: "Federated Timeline"), .federated: .init(title: "Federated Timeline"),
@ -34,44 +32,43 @@ enum TabEnum: String, AppEnum, Sendable {
.followedTags: .init(title: "Followed Tags"), .followedTags: .init(title: "Followed Tags"),
.lists: .init(title: "Lists"), .lists: .init(title: "Lists"),
.links: .init(title: "Trending Links"), .links: .init(title: "Trending Links"),
.post: .init(title: "New post"), .post: .init(title: "New post")]
]
} }
var toAppTab: Tab { var toAppTab: Tab {
switch self { switch self {
case .timeline: case .timeline:
.timeline .timeline
case .notifications: case .notifications:
.notifications .notifications
case .mentions: case .mentions:
.mentions .mentions
case .explore: case .explore:
.explore .explore
case .messages: case .messages:
.messages .messages
case .settings: case .settings:
.settings .settings
case .trending: case .trending:
.trending .trending
case .federated: case .federated:
.federated .federated
case .local: case .local:
.local .local
case .profile: case .profile:
.profile .profile
case .bookmarks: case .bookmarks:
.bookmarks .bookmarks
case .favorites: case .favorites:
.favorites .favorites
case .post: case .post:
.post .post
case .followedTags: case .followedTags:
.followedTags .followedTags
case .lists: case .lists:
.lists .lists
case .links: case .links:
.links .links
} }
} }
} }
@ -79,15 +76,14 @@ enum TabEnum: String, AppEnum, Sendable {
struct TabIntent: AppIntent { struct TabIntent: AppIntent {
static let title: LocalizedStringResource = "Open on a tab" static let title: LocalizedStringResource = "Open on a tab"
static var description: IntentDescription { static var description: IntentDescription {
get { "Open the app on a specific tab"
"Open the app on a specific tab"
}
} }
static let openAppWhenRun: Bool = true static let openAppWhenRun: Bool = true
@Parameter(title: "Selected tab") @Parameter(title: "Selected tab")
var tab: TabEnum var tab: TabEnum
@MainActor @MainActor
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
AppIntentService.shared.handledIntent = .init(intent: self) AppIntentService.shared.handledIntent = .init(intent: self)

View file

@ -326,20 +326,19 @@ public struct AccountDetailView: View {
if let account = viewModel.account { if let account = viewModel.account {
Divider() Divider()
Button { Button {
routerPath.navigate(to: .blockedAccounts) routerPath.navigate(to: .blockedAccounts)
} label: { } label: {
Label("account.blocked", systemImage: "person.crop.circle.badge.xmark") Label("account.blocked", systemImage: "person.crop.circle.badge.xmark")
} }
Button { Button {
routerPath.navigate(to: .mutedAccounts) routerPath.navigate(to: .mutedAccounts)
} label: { } label: {
Label("account.muted", systemImage: "person.crop.circle.badge.moon") Label("account.muted", systemImage: "person.crop.circle.badge.moon")
} }
Divider() Divider()
Button { Button {

View file

@ -89,10 +89,10 @@ public enum AccountsListMode {
case let .accountsList(accounts): case let .accountsList(accounts):
self.accounts = accounts self.accounts = accounts
link = nil link = nil
case .blocked: case .blocked:
(accounts, link) = try await client.getWithLink(endpoint: Accounts.blockList) (accounts, link) = try await client.getWithLink(endpoint: Accounts.blockList)
case .muted: case .muted:
(accounts, link) = try await client.getWithLink(endpoint: Accounts.muteList) (accounts, link) = try await client.getWithLink(endpoint: Accounts.muteList)
} }
@ -125,14 +125,14 @@ public enum AccountsListMode {
case .accountsList: case .accountsList:
newAccounts = [] newAccounts = []
link = nil link = nil
case .blocked: case .blocked:
(newAccounts, link) = try await client.getWithLink(endpoint: Accounts.blockList) (newAccounts, link) = try await client.getWithLink(endpoint: Accounts.blockList)
case .muted: case .muted:
(newAccounts, link) = try await client.getWithLink(endpoint: Accounts.muteList) (newAccounts, link) = try await client.getWithLink(endpoint: Accounts.muteList)
} }
accounts.append(contentsOf: newAccounts) accounts.append(contentsOf: newAccounts)
let newRelationships: [Relationship] = let newRelationships: [Relationship] =
try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map(\.id))) try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map(\.id)))

View file

@ -10,7 +10,7 @@ public struct CloseToolbarItem: ToolbarContent {
Button(action: { Button(action: {
dismiss() dismiss()
}, label: { }, label: {
Image(systemName: "xmark.circle") Image(systemName: "xmark.circle")
}) })
.keyboardShortcut(.cancelAction) .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: (() async -> Void) public let onButtonPress: () async -> Void
public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() async -> 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

View file

@ -34,7 +34,7 @@ import Observation
public var isEditAltTextSupported: Bool { public var isEditAltTextSupported: Bool {
version >= 4.1 version >= 4.1
} }
public var isNotificationsFilterSupported: Bool { public var isNotificationsFilterSupported: Bool {
version >= 4.3 version >= 4.3
} }

View file

@ -81,7 +81,7 @@ public enum SheetDestination: Identifiable, Hashable {
public var id: String { public var id: String {
switch self { switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor,
.mentionStatusEditor, .quoteLinkStatusEditor, .prefilledStatusEditor, .imageURL: .mentionStatusEditor, .quoteLinkStatusEditor, .prefilledStatusEditor, .imageURL:
"statusEditor" "statusEditor"
case .listCreate: case .listCreate:
"listCreate" "listCreate"
@ -177,9 +177,10 @@ public enum SheetDestination: Identifiable, Hashable {
} }
return .handled return .handled
} else if let client, } else if let client,
client.isAuth, client.isAuth,
client.hasConnection(with: url), client.hasConnection(with: url),
let id = Int(url.lastPathComponent) { let id = Int(url.lastPathComponent)
{
if url.absoluteString.contains(client.server) { if url.absoluteString.contains(client.server) {
navigate(to: .statusDetail(id: String(id))) navigate(to: .statusDetail(id: String(id)))
} else { } else {
@ -189,11 +190,12 @@ public enum SheetDestination: Identifiable, Hashable {
} }
return urlHandler?(url) ?? .systemAction return urlHandler?(url) ?? .systemAction
} }
public func handleDeepLink(url: URL) -> OpenURLAction.Result { public func handleDeepLink(url: URL) -> OpenURLAction.Result {
guard let client, guard let client,
client.isAuth, client.isAuth,
let id = Int(url.lastPathComponent) else { let id = Int(url.lastPathComponent)
else {
return urlHandler?(url) ?? .systemAction return urlHandler?(url) ?? .systemAction
} }
// First check whether we already know that the client's server federates with the server this post is on // First check whether we already know that the client's server federates with the server this post is on
@ -211,18 +213,18 @@ public enum SheetDestination: Identifiable, Hashable {
handlerOrDefault(url: url) handlerOrDefault(url: url)
return return
} }
guard client.hasConnection(with: url) else { guard client.hasConnection(with: url) else {
handlerOrDefault(url: url) handlerOrDefault(url: url)
return return
} }
navigateToStatus(url: url, id: id) navigateToStatus(url: url, id: id)
} }
return .handled return .handled
} }
private func navigateToStatus(url: URL, id: Int) { private func navigateToStatus(url: URL, id: Int) {
guard let client else { return } guard let client else { return }
if url.absoluteString.contains(client.server) { if url.absoluteString.contains(client.server) {
@ -231,7 +233,7 @@ public enum SheetDestination: Identifiable, Hashable {
navigate(to: .remoteStatusDetail(url: url)) navigate(to: .remoteStatusDetail(url: url))
} }
} }
private func handlerOrDefault(url: URL) { private func handlerOrDefault(url: URL) {
if let urlHandler { if let urlHandler {
_ = urlHandler(url) _ = urlHandler(url)

View file

@ -6,7 +6,7 @@ public struct NotificationsPolicy: Codable, Sendable {
public var filterNewAccounts: Bool public var filterNewAccounts: Bool
public var filterPrivateMentions: Bool public var filterPrivateMentions: Bool
public let summary: Summary public let summary: Summary
public struct Summary: Codable, Sendable { public struct Summary: Codable, Sendable {
public let pendingRequestsCount: String public let pendingRequestsCount: String
public let pendingNotificationsCount: String public let pendingNotificationsCount: String

View file

@ -33,7 +33,7 @@ public enum Notifications: Endpoint {
"notifications/clear" "notifications/clear"
} }
} }
public var jsonValue: (any Encodable)? { public var jsonValue: (any Encodable)? {
switch self { switch self {
case let .putPolicy(policy): case let .putPolicy(policy):

View file

@ -1,14 +1,14 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Env import Env
import Models
import SwiftUI
struct NotificationsHeaderFilteredView: View { struct NotificationsHeaderFilteredView: View {
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
let filteredNotifications: NotificationsPolicy.Summary let filteredNotifications: NotificationsPolicy.Summary
var body: some View { var body: some View {
if let count = Int(filteredNotifications.pendingNotificationsCount), count > 0 { if let count = Int(filteredNotifications.pendingNotificationsCount), count > 0 {
HStack { HStack {

View file

@ -7,14 +7,14 @@ 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 @Environment(CurrentInstance.self) private var currentInstance
@State private var viewModel = NotificationsViewModel() @State private var viewModel = NotificationsViewModel()
@State private var isNotificationsPolicyPresented: Bool = false @State private var isNotificationsPolicyPresented: Bool = false
@Binding var scrollToTopSignal: Int @Binding var scrollToTopSignal: Int
@ -24,7 +24,8 @@ public struct NotificationsListView: View {
public init(lockedType: Models.Notification.NotificationType? = nil, public init(lockedType: Models.Notification.NotificationType? = nil,
lockedAccountId: String? = nil, lockedAccountId: String? = nil,
scrollToTopSignal: Binding<Int>) { scrollToTopSignal: Binding<Int>)
{
self.lockedType = lockedType self.lockedType = lockedType
self.lockedAccountId = lockedAccountId self.lockedAccountId = lockedAccountId
_scrollToTopSignal = scrollToTopSignal _scrollToTopSignal = scrollToTopSignal
@ -113,7 +114,7 @@ public struct NotificationsListView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
#endif #endif
.onAppear { .onAppear {
viewModel.client = client viewModel.client = client
viewModel.currentAccount = account viewModel.currentAccount = account
if let lockedType { if let lockedType {

View file

@ -1,50 +1,50 @@
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Models import Models
import Network
import SwiftUI
@MainActor @MainActor
struct NotificationsPolicyView: View { struct NotificationsPolicyView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(Client.self) private var client @Environment(Client.self) private var client
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@State private var policy: NotificationsPolicy? @State private var policy: NotificationsPolicy?
@State private var isUpdating: Bool = false @State private var isUpdating: Bool = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("notifications.content-filter.title-inline") { Section("notifications.content-filter.title-inline") {
Toggle(isOn: .init(get: { policy?.filterNotFollowing == true }, Toggle(isOn: .init(get: { policy?.filterNotFollowing == true },
set: { newValue in set: { newValue in
policy?.filterNotFollowing = newValue policy?.filterNotFollowing = newValue
Task { await updatePolicy() } Task { await updatePolicy() }
}), label: { }), label: {
Text("notifications.content-filter.peopleYouDontFollow") Text("notifications.content-filter.peopleYouDontFollow")
}) })
Toggle(isOn: .init(get: { policy?.filterNotFollowers == true }, Toggle(isOn: .init(get: { policy?.filterNotFollowers == true },
set: { newValue in set: { newValue in
policy?.filterNotFollowers = newValue policy?.filterNotFollowers = newValue
Task { await updatePolicy() } Task { await updatePolicy() }
}), label: { }), label: {
Text("notifications.content-filter.peopleNotFollowingYou") Text("notifications.content-filter.peopleNotFollowingYou")
}) })
Toggle(isOn: .init(get: { policy?.filterNewAccounts == true }, Toggle(isOn: .init(get: { policy?.filterNewAccounts == true },
set: { newValue in set: { newValue in
policy?.filterNewAccounts = newValue policy?.filterNewAccounts = newValue
Task { await updatePolicy() } Task { await updatePolicy() }
}), label: { }), label: {
Text("notifications.content-filter.newAccounts") Text("notifications.content-filter.newAccounts")
}) })
Toggle(isOn: .init(get: { policy?.filterPrivateMentions == true }, Toggle(isOn: .init(get: { policy?.filterPrivateMentions == true },
set: { newValue in set: { newValue in
policy?.filterPrivateMentions = newValue policy?.filterPrivateMentions = newValue
Task { await updatePolicy() } Task { await updatePolicy() }
}), label: { }), label: {
Text("notifications.content-filter.privateMentions") Text("notifications.content-filter.privateMentions")
}) })
} }
#if !os(visionOS) #if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
@ -63,7 +63,7 @@ struct NotificationsPolicyView: View {
.presentationDetents([.medium]) .presentationDetents([.medium])
.presentationBackground(.thinMaterial) .presentationBackground(.thinMaterial)
} }
private func getPolicy() async { private func getPolicy() async {
defer { defer {
isUpdating = false isUpdating = false
@ -75,7 +75,7 @@ struct NotificationsPolicyView: View {
dismiss() dismiss()
} }
} }
private func updatePolicy() async { private func updatePolicy() async {
if let policy { if let policy {
defer { defer {
@ -84,7 +84,7 @@ struct NotificationsPolicyView: View {
do { do {
isUpdating = true isUpdating = true
self.policy = try await client.put(endpoint: Notifications.putPolicy(policy: policy)) self.policy = try await client.put(endpoint: Notifications.putPolicy(policy: policy))
} catch { } } catch {}
} }
} }
} }

View file

@ -39,7 +39,7 @@ 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 lockedAccountId: String?
var policy: Models.NotificationsPolicy? var policy: Models.NotificationsPolicy?
var selectedType: Models.Notification.NotificationType? { var selectedType: Models.Notification.NotificationType? {
didSet { didSet {
@ -156,7 +156,7 @@ import SwiftUI
let newNotifications: [Models.Notification] let newNotifications: [Models.Notification]
if let lockedAccountId { if let lockedAccountId {
newNotifications = newNotifications =
try await client.get(endpoint: Notifications.notificationsForAccount(accountId: lockedAccountId, maxId: lastId)) try await client.get(endpoint: Notifications.notificationsForAccount(accountId: lockedAccountId, maxId: lastId))
} else { } else {
newNotifications = newNotifications =
try await client.get(endpoint: Notifications.notifications(minId: nil, try await client.get(endpoint: Notifications.notifications(minId: nil,
@ -180,7 +180,7 @@ import SwiftUI
} catch {} } catch {}
} }
} }
func fetchPolicy() async { func fetchPolicy() async {
policy = try? await client?.get(endpoint: Notifications.policy) policy = try? await client?.get(endpoint: Notifications.policy)
} }

View file

@ -1,30 +1,31 @@
import SwiftUI
import Network
import Models
import DesignSystem import DesignSystem
import Models
import Network
import SwiftUI
@MainActor @MainActor
public struct NotificationsRequestsListView: View { public struct NotificationsRequestsListView: View {
@Environment(Client.self) private var client @Environment(Client.self) private var client
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
enum ViewState { enum ViewState {
case loading case loading
case error case error
case requests(_ data: [NotificationsRequest]) case requests(_ data: [NotificationsRequest])
} }
@State private var viewState: ViewState = .loading @State private var viewState: ViewState = .loading
public init() { } public init() {}
public var body: some View { public var body: some View {
List { List {
switch viewState { switch viewState {
case .loading: case .loading:
ProgressView() ProgressView()
#if !os(visionOS) #if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif #endif
.listSectionSeparator(.hidden) .listSectionSeparator(.hidden)
case .error: case .error:
ErrorView(title: "notifications.error.title", ErrorView(title: "notifications.error.title",
@ -33,10 +34,10 @@ public struct NotificationsRequestsListView: View {
{ {
await fetchRequests() await fetchRequests()
} }
#if !os(visionOS) #if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif #endif
.listSectionSeparator(.hidden) .listSectionSeparator(.hidden)
case let .requests(data): case let .requests(data):
ForEach(data) { request in ForEach(data) { request in
NotificationsRequestsRowView(request: request) NotificationsRequestsRowView(request: request)
@ -46,7 +47,7 @@ public struct NotificationsRequestsListView: View {
} label: { } label: {
Label("account.follow-request.accept", systemImage: "checkmark") Label("account.follow-request.accept", systemImage: "checkmark")
} }
Button { Button {
Task { await dismissRequest(request) } Task { await dismissRequest(request) }
} label: { } label: {
@ -59,32 +60,32 @@ public struct NotificationsRequestsListView: View {
} }
.listStyle(.plain) .listStyle(.plain)
#if !os(visionOS) #if !os(visionOS)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
#endif #endif
.navigationTitle("notifications.content-filter.requests.title") .navigationTitle("notifications.content-filter.requests.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
await fetchRequests() await fetchRequests()
} }
.refreshable { .refreshable {
await fetchRequests() await fetchRequests()
} }
} }
private func fetchRequests() async { private func fetchRequests() async {
do { do {
viewState = .requests(try await client.get(endpoint: Notifications.requests)) viewState = try .requests(await client.get(endpoint: Notifications.requests))
} catch { } catch {
viewState = .error viewState = .error
} }
} }
private func acceptRequest(_ request: NotificationsRequest) async { private func acceptRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.acceptRequest(id: request.id)) _ = try? await client.post(endpoint: Notifications.acceptRequest(id: request.id))
await fetchRequests() await fetchRequests()
} }
private func dismissRequest(_ request: NotificationsRequest) async { private func dismissRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.dismissRequest(id: request.id)) _ = try? await client.post(endpoint: Notifications.dismissRequest(id: request.id))
await fetchRequests() await fetchRequests()

View file

@ -1,20 +1,20 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Env import Env
import Models
import Network import Network
import SwiftUI
struct NotificationsRequestsRowView: View { struct NotificationsRequestsRowView: View {
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
@Environment(Client.self) private var client @Environment(Client.self) private var client
let request: NotificationsRequest let request: NotificationsRequest
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
AvatarView(request.account.avatar, config: .embed) AvatarView(request.account.avatar, config: .embed)
VStack(alignment: .leading) { VStack(alignment: .leading) {
EmojiTextApp(request.account.cachedDisplayName, emojis: request.account.emojis) EmojiTextApp(request.account.cachedDisplayName, emojis: request.account.emojis)
.font(.scaledBody) .font(.scaledBody)
@ -35,14 +35,14 @@ struct NotificationsRequestsRowView: View {
.padding(8) .padding(8)
.background(.secondary) .background(.secondary)
.clipShape(Circle()) .clipShape(Circle())
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.onTapGesture { .onTapGesture {
routerPath.navigate(to: .notificationForAccount(accountId: request.account.id)) routerPath.navigate(to: .notificationForAccount(accountId: request.account.id))
} }
.listRowInsets(.init(top: 12, .listRowInsets(.init(top: 12,
leading: .layoutPadding, leading: .layoutPadding,
bottom: 12, bottom: 12,
trailing: .layoutPadding)) trailing: .layoutPadding))
@ -50,7 +50,7 @@ struct NotificationsRequestsRowView: View {
.listRowBackground(RoundedRectangle(cornerRadius: 8) .listRowBackground(RoundedRectangle(cornerRadius: 8)
.foregroundStyle(.background)) .foregroundStyle(.background))
#else #else
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif #endif
} }
} }

View file

@ -8,11 +8,11 @@ extension StatusEditor {
@MainActor @MainActor
struct CustomEmojisView: View { struct CustomEmojisView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
var viewModel: ViewModel var viewModel: ViewModel
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {

View file

@ -16,30 +16,30 @@ public extension StatusEditor {
@Environment(AppAccountsManager.self) private var appAccounts @Environment(AppAccountsManager.self) private var appAccounts
@Environment(CurrentAccount.self) private var currentAccount @Environment(CurrentAccount.self) private var currentAccount
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@State private var presentationDetent: PresentationDetent = .large @State private var presentationDetent: PresentationDetent = .large
@State private var mainSEVM: ViewModel @State private var mainSEVM: ViewModel
@State private var followUpSEVMs: [ViewModel] = [] @State private var followUpSEVMs: [ViewModel] = []
@State private var editingMediaContainer: MediaContainer? @State private var editingMediaContainer: MediaContainer?
@State private var scrollID: UUID? @State private var scrollID: UUID?
@FocusState private var editorFocusState: EditorFocusState? @FocusState private var editorFocusState: EditorFocusState?
private var focusedSEVM: ViewModel { private var focusedSEVM: ViewModel {
if case let .followUp(id) = editorFocusState, if case let .followUp(id) = editorFocusState,
let sevm = followUpSEVMs.first(where: { $0.id == id }) let sevm = followUpSEVMs.first(where: { $0.id == id })
{ return sevm } { return sevm }
return mainSEVM return mainSEVM
} }
public init(mode: ViewModel.Mode) { public init(mode: ViewModel.Mode) {
_mainSEVM = State(initialValue: ViewModel(mode: mode)) _mainSEVM = State(initialValue: ViewModel(mode: mode))
} }
public var body: some View { public var body: some View {
@Bindable var focusedSEVM = focusedSEVM @Bindable var focusedSEVM = focusedSEVM
NavigationStack { NavigationStack {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ScrollView { ScrollView {
@ -53,10 +53,10 @@ public extension StatusEditor {
isMain: true isMain: true
) )
.id(mainSEVM.id) .id(mainSEVM.id)
ForEach(followUpSEVMs) { sevm in ForEach(followUpSEVMs) { sevm in
@Bindable var sevm: ViewModel = sevm @Bindable var sevm: ViewModel = sevm
EditorView( EditorView(
viewModel: sevm, viewModel: sevm,
followUpSEVMs: $followUpSEVMs, followUpSEVMs: $followUpSEVMs,
@ -73,74 +73,74 @@ public extension StatusEditor {
.scrollPosition(id: $scrollID, anchor: .top) .scrollPosition(id: $scrollID, anchor: .top)
.animation(.bouncy(duration: 0.3), value: editorFocusState) .animation(.bouncy(duration: 0.3), value: editorFocusState)
.animation(.bouncy(duration: 0.3), value: followUpSEVMs) .animation(.bouncy(duration: 0.3), value: followUpSEVMs)
#if !os(visionOS) #if !os(visionOS)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
#endif #endif
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
AutoCompleteView(viewModel: focusedSEVM) AutoCompleteView(viewModel: focusedSEVM)
} }
#if os(visionOS) #if os(visionOS)
.ornament(attachmentAnchor: .scene(.leading)) { .ornament(attachmentAnchor: .scene(.leading)) {
AccessoryView(focusedSEVM: focusedSEVM,
followUpSEVMs: $followUpSEVMs)
}
#else
.safeAreaInset(edge: .bottom) {
if presentationDetent == .large || presentationDetent == .medium {
AccessoryView(focusedSEVM: focusedSEVM, AccessoryView(focusedSEVM: focusedSEVM,
followUpSEVMs: $followUpSEVMs) followUpSEVMs: $followUpSEVMs)
} }
} #else
#endif .safeAreaInset(edge: .bottom) {
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views if presentationDetent == .large || presentationDetent == .medium {
.navigationTitle(focusedSEVM.mode.title) AccessoryView(focusedSEVM: focusedSEVM,
.navigationBarTitleDisplayMode(.inline) followUpSEVMs: $followUpSEVMs)
.toolbar { ToolbarItems(mainSEVM: mainSEVM, }
focusedSEVM: focusedSEVM, }
followUpSEVMs: followUpSEVMs) } #endif
.toolbarBackground(.visible, for: .navigationBar) .accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
.alert( .navigationTitle(focusedSEVM.mode.title)
"status.error.posting.title", .navigationBarTitleDisplayMode(.inline)
isPresented: $focusedSEVM.showPostingErrorAlert, .toolbar { ToolbarItems(mainSEVM: mainSEVM,
actions: { focusedSEVM: focusedSEVM,
Button("OK") {} followUpSEVMs: followUpSEVMs) }
}, message: { .toolbarBackground(.visible, for: .navigationBar)
Text(mainSEVM.postingError ?? "") .alert(
} "status.error.posting.title",
) isPresented: $focusedSEVM.showPostingErrorAlert,
.interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning) actions: {
.onChange(of: appAccounts.currentClient) { _, newValue in Button("OK") {}
if mainSEVM.mode.isInShareExtension { }, message: {
currentAccount.setClient(client: newValue) Text(mainSEVM.postingError ?? "")
mainSEVM.client = newValue }
for post in followUpSEVMs { )
post.client = newValue .interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning)
} .onChange(of: appAccounts.currentClient) { _, newValue in
} if mainSEVM.mode.isInShareExtension {
} currentAccount.setClient(client: newValue)
.onDrop(of: [.image, .video, .gif, .mpeg4Movie, .quickTimeMovie, .movie], mainSEVM.client = newValue
delegate: focusedSEVM) for post in followUpSEVMs {
.onChange(of: currentAccount.account?.id) { post.client = newValue
mainSEVM.currentAccount = currentAccount.account }
for p in followUpSEVMs { }
p.currentAccount = mainSEVM.currentAccount }
} .onDrop(of: [.image, .video, .gif, .mpeg4Movie, .quickTimeMovie, .movie],
} delegate: focusedSEVM)
.onChange(of: mainSEVM.visibility) { .onChange(of: currentAccount.account?.id) {
for p in followUpSEVMs { mainSEVM.currentAccount = currentAccount.account
p.visibility = mainSEVM.visibility for p in followUpSEVMs {
} p.currentAccount = mainSEVM.currentAccount
} }
.onChange(of: followUpSEVMs.count) { oldValue, newValue in }
if oldValue < newValue { .onChange(of: mainSEVM.visibility) {
Task { for p in followUpSEVMs {
try? await Task.sleep(for: .seconds(0.1)) p.visibility = mainSEVM.visibility
withAnimation(.bouncy(duration: 0.5)) { }
scrollID = followUpSEVMs.last?.id }
.onChange(of: followUpSEVMs.count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.1))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = followUpSEVMs.last?.id
}
}
} }
} }
}
}
if mainSEVM.isPosting { if mainSEVM.isPosting {
ProgressView(value: mainSEVM.postingProgress, total: 100.0) ProgressView(value: mainSEVM.postingProgress, total: 100.0)
} }

View file

@ -313,7 +313,7 @@ public extension StatusEditor {
processItemsProvider(items: items) processItemsProvider(items: items)
case let .imageURL(urls, visibility): case let .imageURL(urls, visibility):
Task { Task {
for container in await Self.makeImageContainer(from: urls) { for container in await Self.makeImageContainer(from: urls) {
prepareToPost(for: container) prepareToPost(for: container)
} }
} }
@ -746,16 +746,16 @@ public extension StatusEditor {
error: nil error: nil
) )
} }
private static func makeImageContainer(from urls: [URL]) async -> [MediaContainer] { private static func makeImageContainer(from urls: [URL]) async -> [MediaContainer] {
var containers: [MediaContainer] = [] var containers: [MediaContainer] = []
for url in urls { for url in urls {
let compressor = Compressor() let compressor = Compressor()
if let compressedData = await compressor.compressImageFrom(url: url), if let compressedData = await compressor.compressImageFrom(url: url),
let image = UIImage(data: compressedData) { let image = UIImage(data: compressedData)
{
containers.append(MediaContainer( containers.append(MediaContainer(
id: UUID().uuidString, id: UUID().uuidString,
image: image, image: image,
@ -766,7 +766,7 @@ public extension StatusEditor {
)) ))
} }
} }
return containers return containers
} }