iOS 17+ only support + migrating to Observation framework (#1571)

* Initial iOS 17 + Observable migration

* More Observation

* More observation

* Checkpoint

* Checkpoint

* Bump version to 1.8.0

* SwiftFormat

* Fix home timeline switch on login

* Fix sidebar routerPath

* Fixes on detail view

* Remove print changes

* Simply detail view

* More opt

* Migrate DisplaySettingsLocalValues

* Better post detail transition

* Status detail animation finally right

* Cleanup
This commit is contained in:
Thomas Ricouard 2023-09-18 07:01:23 +02:00 committed by GitHub
parent 3853eff065
commit 4189a59cf6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 820 additions and 812 deletions

View file

@ -975,13 +975,13 @@
INFOPLIST_FILE = IceCubesNotifications/Info.plist; INFOPLIST_FILE = IceCubesNotifications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications; INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1005,13 +1005,13 @@
INFOPLIST_FILE = IceCubesNotifications/Info.plist; INFOPLIST_FILE = IceCubesNotifications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications; INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesNotifications";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1036,13 +1036,13 @@
INFOPLIST_FILE = IceCubesShareExtension/Info.plist; INFOPLIST_FILE = IceCubesShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Ice Cubes"; INFOPLIST_KEY_CFBundleDisplayName = "Ice Cubes";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1066,13 +1066,13 @@
INFOPLIST_FILE = IceCubesShareExtension/Info.plist; INFOPLIST_FILE = IceCubesShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Ice Cubes"; INFOPLIST_KEY_CFBundleDisplayName = "Ice Cubes";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1240,11 +1240,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@ -1293,11 +1293,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@ -1325,13 +1325,13 @@
INFOPLIST_FILE = IceCubesActionExtension/Info.plist; INFOPLIST_FILE = IceCubesActionExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Open in Ice Cube"; INFOPLIST_KEY_CFBundleDisplayName = "Open in Ice Cube";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1356,13 +1356,13 @@
INFOPLIST_FILE = IceCubesActionExtension/Info.plist; INFOPLIST_FILE = IceCubesActionExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Open in Ice Cube"; INFOPLIST_KEY_CFBundleDisplayName = "Open in Ice Cube";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.1; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.7.9; MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).IceCubesApp.IceCubesActionExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View file

@ -112,13 +112,13 @@ extension View {
} }
func withEnvironments() -> some View { func withEnvironments() -> some View {
environmentObject(CurrentAccount.shared) environment(CurrentAccount.shared)
.environmentObject(UserPreferences.shared) .environmentObject(UserPreferences.shared)
.environmentObject(CurrentInstance.shared) .environment(CurrentInstance.shared)
.environmentObject(Theme.shared) .environmentObject(Theme.shared)
.environmentObject(AppAccountsManager.shared) .environment(AppAccountsManager.shared)
.environmentObject(PushNotificationsService.shared) .environment(PushNotificationsService.shared)
.environmentObject(AppAccountsManager.shared.currentClient) .environment(AppAccountsManager.shared.currentClient)
} }
} }

View file

@ -15,15 +15,15 @@ struct IceCubesApp: App {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared @State private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared @State private var currentInstance = CurrentInstance.shared
@StateObject private var currentAccount = CurrentAccount.shared @State private var currentAccount = CurrentAccount.shared
@StateObject private var userPreferences = UserPreferences.shared @StateObject private var userPreferences = UserPreferences.shared
@StateObject private var pushNotificationsService = PushNotificationsService.shared @State private var pushNotificationsService = PushNotificationsService.shared
@StateObject private var watcher = StreamWatcher() @State private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook() @State private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared @StateObject private var theme = Theme.shared
@StateObject private var sidebarRouterPath = RouterPath() @State private var sidebarRouterPath = RouterPath()
@State private var selectedTab: Tab = .timeline @State private var selectedTab: Tab = .timeline
@State private var popToRootTab: Tab = .other @State private var popToRootTab: Tab = .other
@ -43,32 +43,32 @@ struct IceCubesApp: App {
setupRevenueCat() setupRevenueCat()
refreshPushSubs() refreshPushSubs()
} }
.environmentObject(appAccountsManager) .environment(appAccountsManager)
.environmentObject(appAccountsManager.currentClient) .environment(appAccountsManager.currentClient)
.environmentObject(quickLook) .environment(quickLook)
.environmentObject(currentAccount) .environment(currentAccount)
.environmentObject(currentInstance) .environment(currentInstance)
.environmentObject(userPreferences) .environmentObject(userPreferences)
.environmentObject(theme) .environmentObject(theme)
.environmentObject(watcher) .environment(watcher)
.environmentObject(pushNotificationsService) .environment(pushNotificationsService)
.environment(\.isSupporter, isSupporter) .environment(\.isSupporter, isSupporter)
.fullScreenCover(item: $quickLook.url, content: { url in .fullScreenCover(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls) QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom) .edgesIgnoringSafeArea(.bottom)
.background(TransparentBackground()) .background(TransparentBackground())
}) })
.onChange(of: pushNotificationsService.handledNotification) { notification in .onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if notification != nil { if newValue != nil {
pushNotificationsService.handledNotification = nil pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != notification?.account.token.accessToken, if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where: let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == notification?.account.token.accessToken }) { $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
{ {
appAccountsManager.currentAccount = account appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications selectedTab = .notifications
pushNotificationsService.handledNotification = notification pushNotificationsService.handledNotification = newValue
} }
} else { } else {
selectedTab = .notifications selectedTab = .notifications
@ -79,12 +79,12 @@ struct IceCubesApp: App {
.commands { .commands {
appMenu appMenu
} }
.onChange(of: scenePhase) { scenePhase in .onChange(of: scenePhase) { _, newValue in
handleScenePhase(scenePhase: scenePhase) handleScenePhase(scenePhase: newValue)
} }
.onChange(of: appAccountsManager.currentClient) { newClient in .onChange(of: appAccountsManager.currentClient) { _, newValue in
setNewClientsInEnv(client: newClient) setNewClientsInEnv(client: newValue)
if newClient.isAuth { if newValue.isAuth {
watcher.watch(streams: [.user, .direct]) watcher.watch(streams: [.user, .direct])
} }
} }
@ -111,8 +111,7 @@ struct IceCubesApp: App {
private var sidebarView: some View { private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab, SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab, popToRootTab: $popToRootTab,
tabs: availableTabs, tabs: availableTabs)
routerPath: sidebarRouterPath)
{ {
GeometryReader { _ in GeometryReader { _ in
HStack(spacing: 0) { HStack(spacing: 0) {
@ -143,9 +142,10 @@ struct IceCubesApp: App {
} }
} }
} }
}.onChange(of: $appAccountsManager.currentAccount.id) { _ in }.onChange(of: $appAccountsManager.currentAccount.id) {
sideBarLoadedTabs.removeAll() sideBarLoadedTabs.removeAll()
} }
.environment(sidebarRouterPath)
} }
private var notificationsSecondaryColumn: some View { private var notificationsSecondaryColumn: some View {
@ -218,7 +218,7 @@ struct IceCubesApp: App {
watcher.stopWatching() watcher.stopWatching()
case .active: case .active:
watcher.watch(streams: [.user, .direct]) watcher.watch(streams: [.user, .direct])
UIApplication.shared.applicationIconBadgeNumber = 0 UNUserNotificationCenter.current().setBadgeCount(0)
Task { Task {
await userPreferences.refreshServerPreferences() await userPreferences.refreshServerPreferences()
} }

View file

@ -9,7 +9,7 @@ public struct ReportView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
let status: Status let status: Status
@State private var commentText: String = "" @State private var commentText: String = ""

View file

@ -1,5 +1,6 @@
import DesignSystem import DesignSystem
import Env import Env
import Observation
import SafariServices import SafariServices
import SwiftUI import SwiftUI
@ -13,9 +14,9 @@ extension View {
private struct SafariRouter: ViewModifier { private struct SafariRouter: ViewModifier {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@StateObject private var safariManager = InAppSafariManager() @State private var safariManager = InAppSafariManager()
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
@ -58,7 +59,7 @@ private struct SafariRouter: ViewModifier {
} }
@MainActor @MainActor
private class InAppSafariManager: NSObject, ObservableObject, SFSafariViewControllerDelegate { @Observable private class InAppSafariManager: NSObject, SFSafariViewControllerDelegate {
var windowScene: UIWindowScene? var windowScene: UIWindowScene?
let viewController: UIViewController = .init() let viewController: UIViewController = .init()
var window: UIWindow? var window: UIWindow?

View file

@ -6,16 +6,16 @@ import Models
import SwiftUI import SwiftUI
struct SideBarView<Content: View>: View { struct SideBarView<Content: View>: View {
@EnvironmentObject private var appAccounts: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccounts
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var userPreferences: UserPreferences @EnvironmentObject private var userPreferences: UserPreferences
@Environment(RouterPath.self) private var routerPath
@Binding var selectedTab: Tab @Binding var selectedTab: Tab
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var tabs: [Tab] var tabs: [Tab]
@ObservedObject var routerPath = RouterPath()
@ViewBuilder var content: () -> Content @ViewBuilder var content: () -> Content
private func badgeFor(tab: Tab) -> Int { private func badgeFor(tab: Tab) -> Int {
@ -122,6 +122,7 @@ struct SideBarView<Content: View>: View {
} }
var body: some View { var body: some View {
@Bindable var routerPath = routerPath
HStack(spacing: 0) { HStack(spacing: 0) {
ScrollView { ScrollView {
VStack(alignment: .center) { VStack(alignment: .center) {

View file

@ -10,9 +10,9 @@ import SwiftUI
struct ExploreTab: View { struct ExploreTab: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
@ -35,13 +35,13 @@ struct ExploreTab: View {
} }
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .explore { if newValue == .explore {
routerPath.path = [] routerPath.path = []
} }
} }
.onChange(of: client.id) { _ in .onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {

View file

@ -10,11 +10,11 @@ import SwiftUI
struct MessagesTab: View { struct MessagesTab: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var appAccount: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccount
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
@ -32,18 +32,18 @@ struct MessagesTab: View {
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id) .id(client.id)
} }
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .messages { if newValue == .messages {
routerPath.path = [] routerPath.path = []
} }
} }
.onChange(of: client.id) { _ in .onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
} }
} }

View file

@ -12,13 +12,13 @@ struct NotificationsTab: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var appAccount: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccount
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var userPreferences: UserPreferences @EnvironmentObject private var userPreferences: UserPreferences
@EnvironmentObject private var pushNotificationsService: PushNotificationsService @Environment(PushNotificationsService.self) private var pushNotificationsService
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
let lockedType: Models.Notification.NotificationType? let lockedType: Models.Notification.NotificationType?
@ -54,35 +54,35 @@ struct NotificationsTab: View {
} }
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .notifications { if newValue == .notifications {
routerPath.path = [] routerPath.path = []
} }
} }
.onChange(of: pushNotificationsService.handledNotification) { notification in .onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if let notification, let type = notification.notification.supportedType { if let newValue, let type = newValue.notification.supportedType {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch type { switch type {
case .follow, .follow_request: case .follow, .follow_request:
routerPath.navigate(to: .accountDetailWithAccount(account: notification.notification.account)) routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account))
default: default:
if let status = notification.notification.status { if let status = newValue.notification.status {
routerPath.navigate(to: .statusDetailWithStatus(status: status)) routerPath.navigate(to: .statusDetailWithStatus(status: status))
} }
} }
} }
} }
} }
.onChange(of: scenePhase, perform: { scenePhase in .onChange(of: scenePhase) { _, newValue in
switch scenePhase { switch newValue {
case .active: case .active:
clearNotifications() clearNotifications()
default: default:
break break
} }
}) }
.onChange(of: client.id) { _ in .onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
} }

View file

@ -9,11 +9,11 @@ import Shimmer
import SwiftUI import SwiftUI
struct ProfileTab: View { struct ProfileTab: View {
@EnvironmentObject private var appAccount: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccount
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
@ -29,18 +29,18 @@ struct ProfileTab: View {
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
} }
} }
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .profile { if newValue == .profile {
routerPath.path = [] routerPath.path = []
} }
} }
.onChange(of: client.id) { _ in .onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
} }
} }

View file

@ -3,7 +3,7 @@ import Env
import SwiftUI import SwiftUI
struct AboutView: View { struct AboutView: View {
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let versionNumber: String let versionNumber: String

View file

@ -11,12 +11,12 @@ struct AccountSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@EnvironmentObject private var pushNotifications: PushNotificationsService @Environment(PushNotificationsService.self) private var pushNotifications
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccountsManager
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@State private var isEditingAccount: Bool = false @State private var isEditingAccount: Bool = false
@State private var isEditingFilters: Bool = false @State private var isEditingFilters: Bool = false

View file

@ -13,10 +13,10 @@ struct AddAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var appAccountsManager: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var pushNotifications: PushNotificationsService @Environment(PushNotificationsService.self) private var pushNotifications
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""
@ -89,7 +89,7 @@ struct AddAccountView: View {
} }
isSigninIn = false isSigninIn = false
} }
.onChange(of: instanceName) { newValue in .onChange(of: instanceName) { _, newValue in
instanceNamePublisher.send(newValue) instanceNamePublisher.send(newValue)
} }
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { _ in .onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { _ in
@ -119,24 +119,24 @@ struct AddAccountView: View {
} }
} }
} }
.onChange(of: scenePhase, perform: { scenePhase in .onChange(of: scenePhase) { _, newValue in
switch scenePhase { switch newValue {
case .active: case .active:
isSigninIn = false isSigninIn = false
default: default:
break break
} }
}) }
.onOpenURL(perform: { url in .onOpenURL(perform: { url in
Task { Task {
await continueSignIn(url: url) await continueSignIn(url: url)
} }
}) })
.onChange(of: oauthURL, perform: { newValue in .onChange(of: oauthURL) { _, newValue in
if newValue == nil { if newValue == nil {
isSigninIn = false isSigninIn = false
} }
}) }
.sheet(item: $oauthURL, content: { url in .sheet(item: $oauthURL, content: { url in
SafariView(url: url) SafariView(url: url)
}) })

View file

@ -44,7 +44,7 @@ struct ContentSettingsView: View {
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.onChange(of: userPreferences.useInstanceContentSettings) { newVal in .onChange(of: userPreferences.useInstanceContentSettings) { _, newVal in
if newVal { if newVal {
userPreferences.appAutoExpandSpoilers = userPreferences.autoExpandSpoilers userPreferences.appAutoExpandSpoilers = userPreferences.autoExpandSpoilers
userPreferences.appAutoExpandMedia = userPreferences.autoExpandMedia userPreferences.appAutoExpandMedia = userPreferences.autoExpandMedia
@ -93,7 +93,7 @@ struct ContentSettingsView: View {
} }
} }
} }
.onChange(of: userPreferences.postVisibility) { _ in .onChange(of: userPreferences.postVisibility) {
userPreferences.conformReplyVisibilityConstraints() userPreferences.conformReplyVisibilityConstraints()
} }

View file

@ -3,47 +3,20 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import Observation
import Status import Status
import SwiftUI import SwiftUI
class DisplaySettingsLocalValues: ObservableObject { @Observable class DisplaySettingsLocalValues {
@Published var tintColor = Theme.shared.tintColor var tintColor = Theme.shared.tintColor
@Published var primaryBackgroundColor = Theme.shared.primaryBackgroundColor var primaryBackgroundColor = Theme.shared.primaryBackgroundColor
@Published var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor
@Published var labelColor = Theme.shared.labelColor var labelColor = Theme.shared.labelColor
@Published var lineSpacing = Theme.shared.lineSpacing var lineSpacing = Theme.shared.lineSpacing
@Published var fontSizeScale = Theme.shared.fontSizeScale var fontSizeScale = Theme.shared.fontSizeScale
private let debouncesDelay: DispatchQueue.SchedulerTimeType.Stride = .seconds(0.5)
private var subscriptions = Set<AnyCancellable>() init() { }
init() {
$tintColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.tintColor = newColor })
.store(in: &subscriptions)
$primaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.primaryBackgroundColor = newColor })
.store(in: &subscriptions)
$secondaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.secondaryBackgroundColor = newColor })
.store(in: &subscriptions)
$labelColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.labelColor = newColor })
.store(in: &subscriptions)
$lineSpacing
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newSpacing in Theme.shared.lineSpacing = newSpacing })
.store(in: &subscriptions)
$fontSizeScale
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newScale in Theme.shared.fontSizeScale = newScale })
.store(in: &subscriptions)
}
} }
struct DisplaySettingsView: View { struct DisplaySettingsView: View {
@ -53,7 +26,7 @@ struct DisplaySettingsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var userPreferences: UserPreferences @EnvironmentObject private var userPreferences: UserPreferences
@StateObject private var localValues = DisplaySettingsLocalValues() @State private var localValues = DisplaySettingsLocalValues()
@State private var isFontSelectorPresented = false @State private var isFontSelectorPresented = false
@ -64,7 +37,7 @@ struct DisplaySettingsView: View {
var body: some View { var body: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
Form { Form {
StatusRowView(viewModel: { previewStatusViewModel }) StatusRowView(viewModel: previewStatusViewModel)
.allowsHitTesting(false) .allowsHitTesting(false)
.opacity(0) .opacity(0)
.hidden() .hidden()
@ -77,13 +50,37 @@ struct DisplaySettingsView: View {
.navigationTitle("settings.display.navigation-title") .navigationTitle("settings.display.navigation-title")
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor) .background(theme.secondaryBackgroundColor)
.task(id: localValues.tintColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.tintColor = localValues.tintColor
}
.task(id: localValues.primaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.primaryBackgroundColor = localValues.primaryBackgroundColor
}
.task(id: localValues.secondaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.secondaryBackgroundColor = localValues.secondaryBackgroundColor
}
.task(id: localValues.labelColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.labelColor = localValues.labelColor
}
.task(id: localValues.lineSpacing) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.lineSpacing = localValues.lineSpacing
}
.task(id: localValues.fontSizeScale) {
do { try await Task.sleep(for: .microseconds(500)) } catch { }
Theme.shared.fontSizeScale = localValues.fontSizeScale
}
examplePost examplePost
} }
} }
private var examplePost: some View { private var examplePost: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
StatusRowView(viewModel: { previewStatusViewModel }) StatusRowView(viewModel: previewStatusViewModel)
.allowsHitTesting(false) .allowsHitTesting(false)
.padding(.layoutPadding) .padding(.layoutPadding)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
@ -111,7 +108,7 @@ struct DisplaySettingsView: View {
} }
.disabled(theme.followSystemColorScheme) .disabled(theme.followSystemColorScheme)
.opacity(theme.followSystemColorScheme ? 0.5 : 1.0) .opacity(theme.followSystemColorScheme ? 0.5 : 1.0)
.onChange(of: theme.selectedSet) { _ in .onChange(of: theme.selectedSet) {
localValues.tintColor = theme.tintColor localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor localValues.primaryBackgroundColor = theme.primaryBackgroundColor
localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor

View file

@ -9,10 +9,10 @@ import UserNotifications
struct PushNotificationsView: View { struct PushNotificationsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService @Environment(PushNotificationsService.self) private var pushNotifications
@StateObject public var subscription: PushNotificationSubscriptionSettings @State public var subscription: PushNotificationSubscriptionSettings
var body: some View { var body: some View {
Form { Form {

View file

@ -12,15 +12,14 @@ import Timeline
struct SettingsTabs: View { struct SettingsTabs: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var pushNotifications: PushNotificationsService @Environment(PushNotificationsService.self) private var pushNotifications
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var appAccountsManager: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccountsManager
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@State private var addAccountSheetPresented = false @State private var addAccountSheetPresented = false
@State private var isEditingAccount = false @State private var isEditingAccount = false
@State private var cachedRemoved = false @State private var cachedRemoved = false
@ -67,9 +66,9 @@ struct SettingsTabs: View {
} }
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .notifications { if newValue == .notifications {
routerPath.path = [] routerPath.path = []
} }
} }

View file

@ -14,7 +14,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft, createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft,
label: "settings.swipeactions.primary") label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { action in .onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { _, action in
if action == .none { if action == .none {
userPreferences.swipeActionsStatusLeadingRight = .none userPreferences.swipeActionsStatusLeadingRight = .none
} }
@ -29,7 +29,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight, createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight,
label: "settings.swipeactions.primary") label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { action in .onChange(of: userPreferences.swipeActionsStatusTrailingRight) { _, action in
if action == .none { if action == .none {
userPreferences.swipeActionsStatusTrailingLeft = .none userPreferences.swipeActionsStatusTrailingLeft = .none
} }

View file

@ -52,7 +52,9 @@ struct TranslationSettingsView: View {
.navigationTitle("settings.translation.navigation-title") .navigationTitle("settings.translation.navigation-title")
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor) .background(theme.secondaryBackgroundColor)
.onChange(of: apiKey, perform: writeNewValue) .onChange(of: apiKey) {
writeNewValue()
}
.onAppear(perform: updatePrefs) .onAppear(perform: updatePrefs)
} }

View file

@ -58,7 +58,7 @@ struct AddRemoteTimelineView: View {
Button("action.cancel", action: { dismiss() }) Button("action.cancel", action: { dismiss() })
} }
} }
.onChange(of: instanceName) { newValue in .onChange(of: instanceName) { _, newValue in
instanceNamePublisher.send(newValue) instanceNamePublisher.send(newValue)
} }
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in .onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in

View file

@ -93,7 +93,7 @@ struct EditTagGroupView: View {
.onSubmit { .onSubmit {
focusedField = Focus.new focusedField = Focus.new
} }
.onChange(of: sfSymbolName) { _ in .onChange(of: sfSymbolName) {
popupTagsPresented = true popupTagsPresented = true
} }

View file

@ -8,12 +8,12 @@ import SwiftUI
import Timeline import Timeline
struct TimelineTab: View { struct TimelineTab: View {
@EnvironmentObject private var appAccount: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccount
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@StateObject private var routerPath = RouterPath() @State private var routerPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
@ -58,22 +58,22 @@ struct TimelineTab: View {
routerPath.presentedSheet = .addAccount routerPath.presentedSheet = .addAccount
} }
} }
.onChange(of: client.isAuth, perform: { _ in .onChange(of: client.isAuth) {
if client.isAuth { if client.isAuth {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .federated timeline = .federated
} }
}) }
.onChange(of: currentAccount.account?.id, perform: { _ in .onChange(of: currentAccount.account?.id) {
if client.isAuth, canFilterTimeline { if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .federated timeline = .federated
} }
}) }
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { _, newValue in
if popToRootTab == .timeline { if newValue == .timeline {
if routerPath.path.isEmpty { if routerPath.path.isEmpty {
scrollToTopSignal += 1 scrollToTopSignal += 1
} else { } else {
@ -81,16 +81,16 @@ struct TimelineTab: View {
} }
} }
} }
.onChange(of: client.id) { _ in .onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
.onChange(of: timeline) { timeline in .onChange(of: timeline) { _, newValue in
if timeline == .home || timeline == .federated || timeline == .local { if client.isAuth, newValue == .home || newValue == .federated || newValue == .local {
lastTimelineFilter = timeline lastTimelineFilter = newValue
} }
} }
.withSafariRouter() .withSafariRouter()
.environmentObject(routerPath) .environment(routerPath)
} }
@ViewBuilder @ViewBuilder

View file

@ -28,11 +28,11 @@ class ShareViewController: UIViewController {
if let attachments = item.attachments { if let attachments = item.attachments {
let view = StatusEditorView(mode: .shareExtension(items: attachments)) let view = StatusEditorView(mode: .shareExtension(items: attachments))
.environmentObject(UserPreferences.shared) .environmentObject(UserPreferences.shared)
.environmentObject(appAccountsManager) .environment(appAccountsManager)
.environmentObject(client) .environment(client)
.environmentObject(account) .environment(account)
.environmentObject(theme) .environmentObject(theme)
.environmentObject(instance) .environment(instance)
.tint(theme.tintColor) .tint(theme.tintColor)
.preferredColorScheme(colorScheme == .light ? .light : .dark) .preferredColorScheme(colorScheme == .light ? .light : .dark)
let childView = UIHostingController(rootView: view) let childView = UIHostingController(rootView: view)

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Account", name: "Account",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -3,12 +3,12 @@ import Network
import SwiftUI import SwiftUI
public struct AccountDetailContextMenu: View { public struct AccountDetailContextMenu: View {
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@ObservedObject var viewModel: AccountDetailViewModel var viewModel: AccountDetailViewModel
public var body: some View { public var body: some View {
if let account = viewModel.account { if let account = viewModel.account {

View file

@ -12,13 +12,13 @@ struct AccountDetailHeaderView: View {
} }
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var quickLook: QuickLook @Environment(QuickLook.self) private var quickLook
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@Environment(\.isSupporter) private var isSupporter: Bool @Environment(\.isSupporter) private var isSupporter: Bool
@ObservedObject var viewModel: AccountDetailViewModel var viewModel: AccountDetailViewModel
let account: Account let account: Account
let scrollViewProxy: ScrollViewProxy? let scrollViewProxy: ScrollViewProxy?

View file

@ -11,15 +11,15 @@ public struct AccountDetailView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@StateObject private var viewModel: AccountDetailViewModel @State private var viewModel: AccountDetailViewModel
@State private var isCurrentUser: Bool = false @State private var isCurrentUser: Bool = false
@State private var isCreateListAlertPresented: Bool = false @State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = "" @State private var createListTitle: String = ""
@ -30,12 +30,12 @@ public struct AccountDetailView: View {
/// When coming from a URL like a mention tap in a status. /// When coming from a URL like a mention tap in a status.
public init(accountId: String) { public init(accountId: String) {
_viewModel = StateObject(wrappedValue: .init(accountId: accountId)) _viewModel = .init(initialValue: .init(accountId: accountId))
} }
/// When the account is already fetched by the parent caller. /// When the account is already fetched by the parent caller.
public init(account: Account) { public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account)) _viewModel = .init(initialValue: .init(account: account))
} }
public var body: some View { public var body: some View {
@ -114,21 +114,21 @@ public struct AccountDetailView: View {
SoundEffectManager.shared.playSound(of: .refresh) SoundEffectManager.shared.playSound(of: .refresh)
} }
} }
.onChange(of: watcher.latestEvent?.id) { _ in .onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent, if let latestEvent = watcher.latestEvent,
viewModel.accountId == currentAccount.account?.id viewModel.accountId == currentAccount.account?.id
{ {
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount) viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
} }
} }
.onChange(of: isEditingAccount, perform: { isEditing in .onChange(of: isEditingAccount) { _, newValue in
if !isEditing { if !newValue {
Task { Task {
await viewModel.fetchAccount() await viewModel.fetchAccount()
await preferences.refreshServerPreferences() await preferences.refreshServerPreferences()
} }
} }
}) }
.sheet(isPresented: $isEditingAccount, content: { .sheet(isPresented: $isEditingAccount, content: {
EditAccountView() EditAccountView()
}) })
@ -292,7 +292,7 @@ public struct AccountDetailView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
ForEach(viewModel.pinned) { status in ForEach(viewModel.pinned) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
} }
Rectangle() Rectangle()
.fill(theme.secondaryBackgroundColor) .fill(theme.secondaryBackgroundColor)

View file

@ -1,11 +1,12 @@
import Env import Env
import Models import Models
import Network import Network
import Observation
import Status import Status
import SwiftUI import SwiftUI
@MainActor @MainActor
class AccountDetailViewModel: ObservableObject, StatusesFetcher { @Observable class AccountDetailViewModel: StatusesFetcher {
let accountId: String let accountId: String
var client: Client? var client: Client?
var isCurrentUser: Bool = false var isCurrentUser: Bool = false
@ -56,8 +57,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
case lists case lists
} }
@Published var accountState: AccountState = .loading var accountState: AccountState = .loading
@Published var tabState: TabState = .statuses(statusesState: .loading) { var tabState: TabState = .statuses(statusesState: .loading) {
didSet { didSet {
/// Forward viewModel tabState related to statusesState to statusesState property /// Forward viewModel tabState related to statusesState to statusesState property
/// for `StatusesFetcher` conformance as we wrap StatusesState in TabState /// for `StatusesFetcher` conformance as we wrap StatusesState in TabState
@ -70,18 +71,18 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
@Published var statusesState: StatusesState = .loading var statusesState: StatusesState = .loading
@Published var relationship: Relationship? var relationship: Relationship?
@Published var pinned: [Status] = [] var pinned: [Status] = []
@Published var favorites: [Status] = [] var favorites: [Status] = []
@Published var bookmarks: [Status] = [] var bookmarks: [Status] = []
private var favoritesNextPage: LinkHandler? private var favoritesNextPage: LinkHandler?
private var bookmarksNextPage: LinkHandler? private var bookmarksNextPage: LinkHandler?
@Published var featuredTags: [FeaturedTag] = [] var featuredTags: [FeaturedTag] = []
@Published var fields: [Account.Field] = [] var fields: [Account.Field] = []
@Published var familiarFollowers: [Account] = [] var familiarFollowers: [Account] = []
@Published var selectedTab = Tab.statuses { var selectedTab = Tab.statuses {
didSet { didSet {
switch selectedTab { switch selectedTab {
case .statuses, .postsAndReplies, .media: case .statuses, .postsAndReplies, .media:
@ -95,8 +96,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
@Published var translation: Translation? var translation: Translation?
@Published var isLoadingTranslation = false var isLoadingTranslation = false
private(set) var account: Account? private(set) var account: Account?
private var tabTask: Task<Void, Never>? private var tabTask: Task<Void, Never>?

View file

@ -4,14 +4,15 @@ import EmojiText
import Env import Env
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class AccountsListRowViewModel: ObservableObject { @Observable public class AccountsListRowViewModel {
var client: Client? var client: Client?
@Published var account: Account var account: Account
@Published var relationShip: Relationship? var relationShip: Relationship?
public init(account: Account, relationShip: Relationship? = nil) { public init(account: Account, relationShip: Relationship? = nil) {
self.account = account self.account = account
@ -21,11 +22,11 @@ public class AccountsListRowViewModel: ObservableObject {
public struct AccountsListRow: View { public struct AccountsListRow: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@StateObject var viewModel: AccountsListRowViewModel @State var viewModel: AccountsListRowViewModel
@State private var isEditingRelationshipNote: Bool = false @State private var isEditingRelationshipNote: Bool = false
@ -33,7 +34,7 @@ public struct AccountsListRow: View {
let requestUpdated: (() -> Void)? let requestUpdated: (() -> Void)?
public init(viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false, requestUpdated: (() -> Void)? = nil) { public init(viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false, requestUpdated: (() -> Void)? = nil) {
_viewModel = StateObject(wrappedValue: viewModel) self.viewModel = viewModel
self.isFollowRequest = isFollowRequest self.isFollowRequest = isFollowRequest
self.requestUpdated = requestUpdated self.requestUpdated = requestUpdated
} }
@ -117,8 +118,8 @@ public struct AccountsListRow: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
.environmentObject(theme) .environmentObject(theme)
.environmentObject(currentAccount) .environment(currentAccount)
.environmentObject(client) .environment(client)
} }
} }
} }

View file

@ -7,13 +7,13 @@ import SwiftUI
public struct AccountsListView: View { public struct AccountsListView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@StateObject private var viewModel: AccountsListViewModel @State private var viewModel: AccountsListViewModel
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
public init(mode: AccountsListMode) { public init(mode: AccountsListMode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode)) _viewModel = .init(initialValue: .init(mode: mode))
} }
public var body: some View { public var body: some View {

View file

@ -1,5 +1,6 @@
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
public enum AccountsListMode { public enum AccountsListMode {
@ -24,7 +25,7 @@ public enum AccountsListMode {
} }
@MainActor @MainActor
class AccountsListViewModel: ObservableObject { @Observable class AccountsListViewModel {
var client: Client? var client: Client?
let mode: AccountsListMode let mode: AccountsListMode
@ -44,7 +45,7 @@ class AccountsListViewModel: ObservableObject {
private var accounts: [Account] = [] private var accounts: [Account] = []
private var relationships: [Relationship] = [] private var relationships: [Relationship] = []
@Published var state = State.loading var state = State.loading
private var nextPageId: String? private var nextPageId: String?

View file

@ -5,10 +5,10 @@ import SwiftUI
public struct EditAccountView: View { public struct EditAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var viewModel = EditAccountViewModel() @State private var viewModel = EditAccountViewModel()
public init() {} public init() {}

View file

@ -1,13 +1,14 @@
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class EditAccountViewModel: ObservableObject { @Observable class EditAccountViewModel {
class FieldEditViewModel: ObservableObject, Identifiable { @Observable class FieldEditViewModel: Identifiable {
let id = UUID().uuidString let id = UUID().uuidString
@Published var name: String = "" var name: String = ""
@Published var value: String = "" var value: String = ""
init(name: String, value: String) { init(name: String, value: String) {
self.name = name self.name = name
@ -17,18 +18,18 @@ class EditAccountViewModel: ObservableObject {
public var client: Client? public var client: Client?
@Published var displayName: String = "" var displayName: String = ""
@Published var note: String = "" var note: String = ""
@Published var postPrivacy = Models.Visibility.pub var postPrivacy = Models.Visibility.pub
@Published var isSensitive: Bool = false var isSensitive: Bool = false
@Published var isBot: Bool = false var isBot: Bool = false
@Published var isLocked: Bool = false var isLocked: Bool = false
@Published var isDiscoverable: Bool = false var isDiscoverable: Bool = false
@Published var fields: [FieldEditViewModel] = [] var fields: [FieldEditViewModel] = []
@Published var isLoading: Bool = true var isLoading: Bool = true
@Published var isSaving: Bool = false var isSaving: Bool = false
@Published var saveError: Bool = false var saveError: Bool = false
init() {} init() {}

View file

@ -5,12 +5,10 @@ import SwiftUI
public struct EditRelationshipNoteView: View { public struct EditRelationshipNoteView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
// need this model to refresh after storing the new note on mastodon @State var accountDetailViewModel: AccountDetailViewModel
var accountDetailViewModel: AccountDetailViewModel @State private var viewModel = EditRelationshipNoteViewModel()
@StateObject private var viewModel = EditRelationshipNoteViewModel()
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {

View file

@ -1,14 +1,15 @@
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class EditRelationshipNoteViewModel: ObservableObject { @Observable class EditRelationshipNoteViewModel {
public var note: String = "" public var note: String = ""
public var relatedAccountId: String? public var relatedAccountId: String?
public var client: Client? public var client: Client?
@Published var isSaving: Bool = false var isSaving: Bool = false
@Published var saveError: Bool = false var saveError: Bool = false
init() {} init() {}
@ -18,7 +19,7 @@ class EditRelationshipNoteViewModel: ObservableObject {
{ {
isSaving = true isSaving = true
do { do {
let _ = try await client!.post(endpoint: Accounts.relationshipNote(id: relatedAccountId!, json: RelationshipNoteData(note: note))) _ = try await client!.post(endpoint: Accounts.relationshipNote(id: relatedAccountId!, json: RelationshipNoteData(note: note)))
} catch { } catch {
isSaving = false isSaving = false
saveError = true saveError = true

View file

@ -8,8 +8,8 @@ struct EditFilterView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var account: CurrentAccount @Environment(CurrentAccount.self) private var account
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@State private var isSavingFilter: Bool = false @State private var isSavingFilter: Bool = false
@State private var filter: ServerFilter? @State private var filter: ServerFilter?
@ -91,9 +91,9 @@ struct EditFilterView: View {
Text(duration.description).tag(duration) Text(duration.description).tag(duration)
} }
} }
.onChange(of: expirySelection) { duration in .onChange(of: expirySelection) { _, newValue in
if duration != .custom { if newValue != .custom {
expiresAt = Date(timeIntervalSinceNow: TimeInterval(duration.rawValue)) expiresAt = Date(timeIntervalSinceNow: TimeInterval(newValue.rawValue))
} }
} }
if expirySelection != .infinite { if expirySelection != .infinite {
@ -227,7 +227,7 @@ struct EditFilterView: View {
} label: { } label: {
EmptyView() EmptyView()
} }
.onChange(of: filterAction) { _ in .onChange(of: filterAction) {
Task { Task {
await saveFilter() await saveFilter()
} }

View file

@ -8,8 +8,8 @@ public struct FiltersListView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var account: CurrentAccount @Environment(CurrentAccount.self) private var account
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@State private var isLoading: Bool = true @State private var isLoading: Bool = true
@State private var filters: [ServerFilter] = [] @State private var filters: [ServerFilter] = []

View file

@ -2,17 +2,18 @@ import Combine
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class FollowButtonViewModel: ObservableObject { @Observable public class FollowButtonViewModel {
var client: Client? var client: Client?
public let accountId: String public let accountId: String
public let shouldDisplayNotify: Bool public let shouldDisplayNotify: Bool
public let relationshipUpdated: (Relationship) -> Void public let relationshipUpdated: (Relationship) -> Void
@Published public private(set) var relationship: Relationship public private(set) var relationship: Relationship
@Published public private(set) var isUpdating: Bool = false public private(set) var isUpdating: Bool = false
public init(accountId: String, public init(accountId: String,
relationship: Relationship, relationship: Relationship,
@ -75,11 +76,11 @@ public class FollowButtonViewModel: ObservableObject {
} }
public struct FollowButton: View { public struct FollowButton: View {
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@StateObject private var viewModel: FollowButtonViewModel @State private var viewModel: FollowButtonViewModel
public init(viewModel: FollowButtonViewModel) { public init(viewModel: FollowButtonViewModel) {
_viewModel = StateObject(wrappedValue: viewModel) _viewModel = .init(initialValue: viewModel)
} }
public var body: some View { public var body: some View {

View file

@ -7,7 +7,7 @@ let package = Package(
name: "AppAccount", name: "AppAccount",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -5,14 +5,14 @@ import SwiftUI
public struct AppAccountView: View { public struct AppAccountView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var appAccounts: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccounts
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@StateObject var viewModel: AppAccountViewModel @State var viewModel: AppAccountViewModel
public init(viewModel: AppAccountViewModel) { public init(viewModel: AppAccountViewModel) {
_viewModel = .init(wrappedValue: viewModel) self.viewModel = viewModel
} }
public var body: some View { public var body: some View {

View file

@ -2,10 +2,11 @@ import Combine
import DesignSystem import DesignSystem
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class AppAccountViewModel: ObservableObject { @Observable public class AppAccountViewModel {
private static var avatarsCache: [String: UIImage] = [:] private static var avatarsCache: [String: UIImage] = [:]
private static var accountsCache: [String: Account] = [:] private static var accountsCache: [String: Account] = [:]
@ -15,7 +16,7 @@ public class AppAccountViewModel: ObservableObject {
let isInNavigation: Bool let isInNavigation: Bool
let showBadge: Bool let showBadge: Bool
@Published var account: Account? { var account: Account? {
didSet { didSet {
if let account { if let account {
refreshAcct(account: account) refreshAcct(account: account)

View file

@ -2,14 +2,15 @@ import Combine
import Env import Env
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class AppAccountsManager: ObservableObject { @Observable public class AppAccountsManager {
@AppStorage("latestCurrentAccountKey", store: UserPreferences.sharedDefault) @AppStorage("latestCurrentAccountKey", store: UserPreferences.sharedDefault)
public static var latestCurrentAccountKey: String = "" public static var latestCurrentAccountKey: String = ""
@Published public var currentAccount: AppAccount { public var currentAccount: AppAccount {
didSet { didSet {
Self.latestCurrentAccountKey = currentAccount.id Self.latestCurrentAccountKey = currentAccount.id
currentClient = .init(server: currentAccount.server, currentClient = .init(server: currentAccount.server,
@ -17,8 +18,8 @@ public class AppAccountsManager: ObservableObject {
} }
} }
@Published public var availableAccounts: [AppAccount] public var availableAccounts: [AppAccount]
@Published public var currentClient: Client public var currentClient: Client
public var pushAccounts: [PushAccount] { public var pushAccounts: [PushAccount] {
availableAccounts.filter { $0.oauthToken != nil } availableAccounts.filter { $0.oauthToken != nil }

View file

@ -4,11 +4,11 @@ import SwiftUI
public struct AppAccountsSelectorView: View { public struct AppAccountsSelectorView: View {
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var appAccounts: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccounts
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ObservedObject var routerPath: RouterPath var routerPath: RouterPath
@State private var accountsViewModel: [AppAccountViewModel] = [] @State private var accountsViewModel: [AppAccountViewModel] = []
@State private var isPresented: Bool = false @State private var isPresented: Bool = false
@ -61,7 +61,7 @@ public struct AppAccountsSelectorView: View {
} }
} }
}) })
.onChange(of: currentAccount.account?.id) { _ in .onChange(of: currentAccount.account?.id) {
refreshAccounts() refreshAccounts()
} }
.onAppear { .onAppear {

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Conversations", name: "Conversations",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -10,14 +10,14 @@ public struct ConversationDetailView: View {
static let bottomAnchor = "bottom" static let bottomAnchor = "bottom"
} }
@EnvironmentObject private var quickLook: QuickLook @Environment(QuickLook.self) private var quickLook
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@StateObject private var viewModel: ConversationDetailViewModel @State private var viewModel: ConversationDetailViewModel
@FocusState private var isMessageFieldFocused: Bool @FocusState private var isMessageFieldFocused: Bool
@ -25,7 +25,7 @@ public struct ConversationDetailView: View {
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
public init(conversation: Conversation) { public init(conversation: Conversation) {
_viewModel = StateObject(wrappedValue: .init(conversation: conversation)) _viewModel = .init(initialValue: .init(conversation: conversation))
} }
public var body: some View { public var body: some View {
@ -85,7 +85,7 @@ public struct ConversationDetailView: View {
} }
} }
} }
.onChange(of: watcher.latestEvent?.id) { _ in .onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent { if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent) viewModel.handleEvent(event: latestEvent)
DispatchQueue.main.async { DispatchQueue.main.async {

View file

@ -4,16 +4,16 @@ import Network
import SwiftUI import SwiftUI
@MainActor @MainActor
class ConversationDetailViewModel: ObservableObject { @Observable class ConversationDetailViewModel {
var client: Client? var client: Client?
var conversation: Conversation var conversation: Conversation
@Published var isLoadingMessages: Bool = true var isLoadingMessages: Bool = true
@Published var messages: [Status] = [] var messages: [Status] = []
@Published var isSendingMessage: Bool = false var isSendingMessage: Bool = false
@Published var newMessageText: String = "" var newMessageText: String = ""
init(conversation: Conversation) { init(conversation: Conversation) {
self.conversation = conversation self.conversation = conversation

View file

@ -6,10 +6,10 @@ import NukeUI
import SwiftUI import SwiftUI
struct ConversationMessageView: View { struct ConversationMessageView: View {
@EnvironmentObject private var quickLook: QuickLook @Environment(QuickLook.self) private var quickLook
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let message: Status let message: Status

View file

@ -6,13 +6,13 @@ import Network
import SwiftUI import SwiftUI
struct ConversationsListRow: View { struct ConversationsListRow: View {
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@Binding var conversation: Conversation @Binding var conversation: Conversation
@ObservedObject var viewModel: ConversationsListViewModel var viewModel: ConversationsListViewModel
var body: some View { var body: some View {
Button { Button {

View file

@ -7,12 +7,12 @@ import SwiftUI
public struct ConversationsListView: View { public struct ConversationsListView: View {
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var viewModel = ConversationsListViewModel() @State private var viewModel = ConversationsListViewModel()
public init() {} public init() {}
@ -83,7 +83,7 @@ public struct ConversationsListView: View {
SecondaryColumnToolbarItem() SecondaryColumnToolbarItem()
} }
} }
.onChange(of: watcher.latestEvent?.id) { _ in .onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent { if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent) viewModel.handleEvent(event: latestEvent)
} }

View file

@ -3,13 +3,13 @@ import Network
import SwiftUI import SwiftUI
@MainActor @MainActor
class ConversationsListViewModel: ObservableObject { @Observable class ConversationsListViewModel {
var client: Client? var client: Client?
@Published var isLoadingFirstPage: Bool = true var isLoadingFirstPage: Bool = true
@Published var isLoadingNextPage: Bool = false var isLoadingNextPage: Bool = false
@Published var conversations: [Conversation] = [] var conversations: [Conversation] = []
@Published var isError: Bool = false var isError: Bool = false
var nextPage: LinkHandler? var nextPage: LinkHandler?

View file

@ -7,7 +7,7 @@ let package = Package(
name: "DesignSystem", name: "DesignSystem",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -1,7 +1,7 @@
import Combine import Combine
import UIKit import UIKit
public class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { @Observable public class SceneDelegate: NSObject, UIWindowSceneDelegate {
public var window: UIWindow? public var window: UIWindow?
public var windowWidth: CGFloat { public var windowWidth: CGFloat {

View file

@ -41,16 +41,16 @@ struct ThemeApplier: ViewModifier {
setWindowUserInterfaceStyle(from: theme.selectedScheme) setWindowUserInterfaceStyle(from: theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor) setBarsColor(theme.primaryBackgroundColor)
} }
.onChange(of: theme.tintColor) { newValue in .onChange(of: theme.tintColor) { _, newValue in
setWindowTint(newValue) setWindowTint(newValue)
} }
.onChange(of: theme.primaryBackgroundColor) { newValue in .onChange(of: theme.primaryBackgroundColor) { _, newValue in
setBarsColor(newValue) setBarsColor(newValue)
} }
.onChange(of: theme.selectedScheme) { newValue in .onChange(of: theme.selectedScheme) { _, newValue in
setWindowUserInterfaceStyle(from: newValue) setWindowUserInterfaceStyle(from: newValue)
} }
.onChange(of: colorScheme) { newColorScheme in .onChange(of: colorScheme) { _, newColorScheme in
if theme.followSystemColorScheme, if theme.followSystemColorScheme,
let sets = availableColorsSets let sets = availableColorsSets
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet }) .first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })

View file

@ -3,6 +3,7 @@ import NukeUI
import Shimmer import Shimmer
import SwiftUI import SwiftUI
@MainActor
public struct AvatarView: View { public struct AvatarView: View {
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme

View file

@ -3,7 +3,7 @@ import Models
import SwiftUI import SwiftUI
public struct FollowRequestButtons: View { public struct FollowRequestButtons: View {
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
let account: Account let account: Account
let requestUpdated: (() -> Void)? let requestUpdated: (() -> Void)?

View file

@ -24,7 +24,7 @@ public extension View {
@MainActor @MainActor
public struct StatusEditorToolbarItem: ToolbarContent { public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
let visibility: Models.Visibility let visibility: Models.Visibility

View file

@ -3,7 +3,7 @@ import Models
import SwiftUI import SwiftUI
public struct TagRowView: View { public struct TagRowView: View {
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
let tag: Tag let tag: Tag

View file

@ -81,7 +81,7 @@ struct ThemeBoxView: View {
.onAppear { .onAppear {
isSelected = theme.selectedSet.rawValue == color.name.rawValue isSelected = theme.selectedSet.rawValue == color.name.rawValue
} }
.onChange(of: theme.selectedSet) { newValue in .onChange(of: theme.selectedSet) { _, newValue in
isSelected = newValue.rawValue == color.name.rawValue isSelected = newValue.rawValue == color.name.rawValue
} }
.onTapGesture { .onTapGesture {

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Env", name: "Env",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -2,18 +2,19 @@ import Combine
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
@MainActor @MainActor
public class CurrentAccount: ObservableObject { @Observable public class CurrentAccount {
private static var accountsCache: [String: Account] = [:] private static var accountsCache: [String: Account] = [:]
@Published public private(set) var account: Account? public private(set) var account: Account?
@Published public private(set) var lists: [List] = [] public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = [] public private(set) var tags: [Tag] = []
@Published public private(set) var followRequests: [Account] = [] public private(set) var followRequests: [Account] = []
@Published public private(set) var isUpdating: Bool = false public private(set) var isUpdating: Bool = false
@Published public private(set) var updatingFollowRequestAccountIds = Set<String>() public private(set) var updatingFollowRequestAccountIds = Set<String>()
@Published public private(set) var isLoadingAccount: Bool = false public private(set) var isLoadingAccount: Bool = false
private var client: Client? private var client: Client?

View file

@ -2,10 +2,11 @@ import Combine
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
@MainActor @MainActor
public class CurrentInstance: ObservableObject { @Observable public class CurrentInstance {
@Published public private(set) var instance: Instance? public private(set) var instance: Instance?
private var client: Client? private var client: Client?

View file

@ -21,11 +21,11 @@ private struct IsSupporter: EnvironmentKey {
static let defaultValue: Bool = false static let defaultValue: Bool = false
} }
private struct IsStatusDetailLoaded: EnvironmentKey { private struct IsStatusFocused: EnvironmentKey {
static let defaultValue: Bool = false static let defaultValue: Bool = false
} }
private struct IsStatusFocused: EnvironmentKey { private struct IsStatusReplyToPrevious: EnvironmentKey {
static let defaultValue: Bool = false static let defaultValue: Bool = false
} }
@ -54,14 +54,14 @@ public extension EnvironmentValues {
get { self[IsSupporter.self] } get { self[IsSupporter.self] }
set { self[IsSupporter.self] = newValue } set { self[IsSupporter.self] = newValue }
} }
var isStatusDetailLoaded: Bool {
get { self[IsStatusDetailLoaded.self] }
set { self[IsStatusDetailLoaded.self] = newValue }
}
var isStatusFocused: Bool { var isStatusFocused: Bool {
get { self[IsStatusFocused.self] } get { self[IsStatusFocused.self] }
set { self[IsStatusFocused.self] = newValue } set { self[IsStatusFocused.self] = newValue }
} }
var isStatusReplyToPrevious: Bool {
get { self[IsStatusReplyToPrevious.self] }
set { self[IsStatusReplyToPrevious.self] = newValue }
}
} }

View file

@ -4,6 +4,7 @@ import Foundation
import KeychainSwift import KeychainSwift
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@ -28,7 +29,7 @@ public struct HandledNotification: Equatable {
} }
@MainActor @MainActor
public class PushNotificationsService: NSObject, ObservableObject { @Observable public class PushNotificationsService: NSObject {
enum Constants { enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev" static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainAuthKey = "notifications_auth_key" static let keychainAuthKey = "notifications_auth_key"
@ -39,9 +40,9 @@ public class PushNotificationsService: NSObject, ObservableObject {
public private(set) var subscriptions: [PushNotificationSubscriptionSettings] = [] public private(set) var subscriptions: [PushNotificationSubscriptionSettings] = []
@Published public var pushToken: Data? public var pushToken: Data?
@Published public var handledNotification: HandledNotification? public var handledNotification: HandledNotification?
override init() { override init() {
super.init() super.init()
@ -162,14 +163,14 @@ extension Data {
} }
@MainActor @MainActor
public class PushNotificationSubscriptionSettings: ObservableObject { @Observable public class PushNotificationSubscriptionSettings {
@Published public var isEnabled: Bool = true public var isEnabled: Bool = true
@Published public var isFollowNotificationEnabled: Bool = true public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true public var isNewPostsNotificationEnabled: Bool = true
public let account: PushAccount public let account: PushAccount

View file

@ -3,8 +3,8 @@ import Combine
import SwiftUI import SwiftUI
@MainActor @MainActor
public class QuickLook: ObservableObject { @Observable public class QuickLook {
@Published public var url: URL? { public var url: URL? {
didSet { didSet {
if url == nil { if url == nil {
cleanup(urls: urls) cleanup(urls: urls)
@ -12,9 +12,9 @@ public class QuickLook: ObservableObject {
} }
} }
@Published public private(set) var urls: [URL] = [] public private(set) var urls: [URL] = []
@Published public private(set) var isPreparing: Bool = false public private(set) var isPreparing: Bool = false
@Published public private(set) var latestError: Error? public private(set) var latestError: Error?
public init() {} public init() {}

View file

@ -2,6 +2,7 @@ import Combine
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
public enum RouterDestination: Hashable { public enum RouterDestination: Hashable {
@ -69,12 +70,12 @@ public enum SheetDestination: Identifiable {
} }
@MainActor @MainActor
public class RouterPath: ObservableObject { @Observable public class RouterPath {
public var client: Client? public var client: Client?
public var urlHandler: ((URL) -> OpenURLAction.Result)? public var urlHandler: ((URL) -> OpenURLAction.Result)?
@Published public var path: [RouterDestination] = [] public var path: [RouterDestination] = []
@Published public var presentedSheet: SheetDestination? public var presentedSheet: SheetDestination?
public init() {} public init() {}

View file

@ -1,10 +1,11 @@
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public protocol StatusDataControlling: ObservableObject { public protocol StatusDataControlling {
var isReblogged: Bool { get set } var isReblogged: Bool { get set }
var isBookmarked: Bool { get set } var isBookmarked: Bool { get set }
var isFavorited: Bool { get set } var isFavorited: Bool { get set }
@ -43,13 +44,13 @@ public final class StatusDataControllerProvider {
for status in statuses { for status in statuses {
let realStatus: AnyStatus = status.reblog ?? status let realStatus: AnyStatus = status.reblog ?? status
let controller = dataController(for: realStatus, client: client) let controller = dataController(for: realStatus, client: client)
controller.updateFrom(status: realStatus, publishUpdate: false) controller.updateFrom(status: realStatus)
} }
} }
} }
@MainActor @MainActor
public final class StatusDataController: StatusDataControlling { @Observable public final class StatusDataController: StatusDataControlling {
private let status: AnyStatus private let status: AnyStatus
private let client: Client private let client: Client
@ -74,7 +75,7 @@ public final class StatusDataController: StatusDataControlling {
favoritesCount = status.favouritesCount favoritesCount = status.favouritesCount
} }
public func updateFrom(status: AnyStatus, publishUpdate: Bool) { public func updateFrom(status: AnyStatus) {
isReblogged = status.reblogged == true isReblogged = status.reblogged == true
isBookmarked = status.bookmarked == true isBookmarked = status.bookmarked == true
isFavorited = status.favourited == true isFavorited = status.favourited == true
@ -82,10 +83,6 @@ public final class StatusDataController: StatusDataControlling {
reblogsCount = status.reblogsCount reblogsCount = status.reblogsCount
repliesCount = status.repliesCount repliesCount = status.repliesCount
favoritesCount = status.favouritesCount favoritesCount = status.favouritesCount
if publishUpdate {
objectWillChange.send()
}
} }
public func toggleFavorite(remoteStatus: String?) async { public func toggleFavorite(remoteStatus: String?) async {
@ -94,14 +91,12 @@ public final class StatusDataController: StatusDataControlling {
let id = remoteStatus ?? status.id let id = remoteStatus ?? status.id
let endpoint = isFavorited ? Statuses.favorite(id: id) : Statuses.unfavorite(id: id) let endpoint = isFavorited ? Statuses.favorite(id: id) : Statuses.unfavorite(id: id)
favoritesCount += isFavorited ? 1 : -1 favoritesCount += isFavorited ? 1 : -1
objectWillChange.send()
do { do {
let status: Status = try await client.post(endpoint: endpoint) let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status, publishUpdate: true) updateFrom(status: status)
} catch { } catch {
isFavorited.toggle() isFavorited.toggle()
favoritesCount += isFavorited ? -1 : 1 favoritesCount += isFavorited ? -1 : 1
objectWillChange.send()
} }
} }
@ -111,14 +106,12 @@ public final class StatusDataController: StatusDataControlling {
let id = remoteStatus ?? status.id let id = remoteStatus ?? status.id
let endpoint = isReblogged ? Statuses.reblog(id: id) : Statuses.unreblog(id: id) let endpoint = isReblogged ? Statuses.reblog(id: id) : Statuses.unreblog(id: id)
reblogsCount += isReblogged ? 1 : -1 reblogsCount += isReblogged ? 1 : -1
objectWillChange.send()
do { do {
let status: Status = try await client.post(endpoint: endpoint) let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status.reblog ?? status, publishUpdate: true) updateFrom(status: status.reblog ?? status)
} catch { } catch {
isReblogged.toggle() isReblogged.toggle()
reblogsCount += isReblogged ? -1 : 1 reblogsCount += isReblogged ? -1 : 1
objectWillChange.send()
} }
} }
@ -127,13 +120,11 @@ public final class StatusDataController: StatusDataControlling {
isBookmarked.toggle() isBookmarked.toggle()
let id = remoteStatus ?? status.id let id = remoteStatus ?? status.id
let endpoint = isBookmarked ? Statuses.bookmark(id: id) : Statuses.unbookmark(id: id) let endpoint = isBookmarked ? Statuses.bookmark(id: id) : Statuses.unbookmark(id: id)
objectWillChange.send()
do { do {
let status: Status = try await client.post(endpoint: endpoint) let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status, publishUpdate: true) updateFrom(status: status)
} catch { } catch {
isBookmarked.toggle() isBookmarked.toggle()
objectWillChange.send()
} }
} }
} }

View file

@ -2,9 +2,10 @@ import Combine
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
@MainActor @MainActor
public class StreamWatcher: ObservableObject { @Observable public class StreamWatcher {
private var client: Client? private var client: Client?
private var task: URLSessionWebSocketTask? private var task: URLSessionWebSocketTask?
private var watchedStreams: [Stream] = [] private var watchedStreams: [Stream] = []
@ -21,9 +22,9 @@ public class StreamWatcher: ObservableObject {
case direct case direct
} }
@Published public var events: [any StreamEvent] = [] public var events: [any StreamEvent] = []
@Published public var unreadNotificationsCount: Int = 0 public var unreadNotificationsCount: Int = 0
@Published public var latestEvent: (any StreamEvent)? public var latestEvent: (any StreamEvent)?
public init() { public init() {
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Explore", name: "Explore",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -9,10 +9,10 @@ import SwiftUI
public struct ExploreView: View { public struct ExploreView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@StateObject private var viewModel = ExploreViewModel() @State private var viewModel = ExploreViewModel()
public init() {} public init() {}
@ -89,6 +89,12 @@ public struct ExploreView: View {
Text(scope.localizedString) Text(scope.localizedString)
} }
} }
.task(id: viewModel.searchQuery) {
do {
try await Task.sleep(for: .milliseconds(150))
await viewModel.search()
} catch {}
}
} }
private var quickAccessView: some View { private var quickAccessView: some View {
@ -117,7 +123,7 @@ public struct ExploreView: View {
private var loadingView: some View { private var loadingView: some View {
ForEach(Status.placeholders()) { status in ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
.padding(.vertical, 8) .padding(.vertical, 8)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
@ -148,7 +154,7 @@ public struct ExploreView: View {
if !results.statuses.isEmpty, viewModel.searchScope == .all || viewModel.searchScope == .posts { if !results.statuses.isEmpty, viewModel.searchScope == .all || viewModel.searchScope == .posts {
Section("explore.section.posts") { Section("explore.section.posts") {
ForEach(results.statuses) { status in ForEach(results.statuses) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
@ -196,7 +202,7 @@ public struct ExploreView: View {
ForEach(viewModel.trendingStatuses ForEach(viewModel.trendingStatuses
.prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count)) .prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count))
{ status in { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8) .padding(.vertical, 8)
} }

View file

@ -1,10 +1,10 @@
import Combine
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class ExploreViewModel: ObservableObject { @Observable class ExploreViewModel {
enum SearchScope: String, CaseIterable { enum SearchScope: String, CaseIterable {
case all, people, hashtags, posts case all, people, hashtags, posts
@ -39,34 +39,23 @@ class ExploreViewModel: ObservableObject {
trendingLinks.isEmpty && trendingTags.isEmpty && trendingStatuses.isEmpty && suggestedAccounts.isEmpty trendingLinks.isEmpty && trendingTags.isEmpty && trendingStatuses.isEmpty && suggestedAccounts.isEmpty
} }
@Published var searchQuery = "" { var searchQuery = "" {
didSet { didSet {
isSearching = true isSearching = true
} }
} }
@Published var results: [String: SearchResults] = [:] var results: [String: SearchResults] = [:]
@Published var isLoaded = false var isLoaded = false
@Published var isSearching = false var isSearching = false
@Published var suggestedAccounts: [Account] = [] var suggestedAccounts: [Account] = []
@Published var suggestedAccountsRelationShips: [Relationship] = [] var suggestedAccountsRelationShips: [Relationship] = []
@Published var trendingTags: [Tag] = [] var trendingTags: [Tag] = []
@Published var trendingStatuses: [Status] = [] var trendingStatuses: [Status] = []
@Published var trendingLinks: [Card] = [] var trendingLinks: [Card] = []
@Published var searchScope: SearchScope = .all var searchScope: SearchScope = .all
private var searchTask: Task<Void, Never>? init() {}
private var cancellables = Set<AnyCancellable>()
init() {
$searchQuery
.removeDuplicates()
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
self?.search()
})
.store(in: &cancellables)
}
func fetchTrending() async { func fetchTrending() async {
guard let client else { return } guard let client else { return }
@ -104,29 +93,24 @@ class ExploreViewModel: ObservableObject {
trendingLinks: trendingLinks) trendingLinks: trendingLinks)
} }
func search() { func search() async {
guard !searchQuery.isEmpty else { return } guard let client else { return }
isSearching = true do {
searchTask?.cancel() try await Task.sleep(for: .milliseconds(250))
searchTask = nil var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery,
searchTask = Task { type: nil,
guard let client else { return } offset: nil,
do { following: nil),
var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery, forceVersion: .v2)
type: nil, let relationships: [Relationship] =
offset: nil, try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map(\.id)))
following: nil), results.relationships = relationships
forceVersion: .v2) withAnimation {
let relationships: [Relationship] = self.results[searchQuery] = results
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map(\.id)))
results.relationships = relationships
withAnimation {
self.results[searchQuery] = results
isSearching = false
}
} catch {
isSearching = false isSearching = false
} }
} catch {
isSearching = false
} }
} }
} }

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Lists", name: "Lists",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -6,16 +6,16 @@ import SwiftUI
public struct ListAddAccountView: View { public struct ListAddAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@StateObject private var viewModel: ListAddAccountViewModel @State private var viewModel: ListAddAccountViewModel
@State private var isCreateListAlertPresented: Bool = false @State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = "" @State private var createListTitle: String = ""
public init(account: Account) { public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account)) _viewModel = .init(initialValue: .init(account: account))
} }
public var body: some View { public var body: some View {

View file

@ -1,13 +1,14 @@
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class ListAddAccountViewModel: ObservableObject { @Observable class ListAddAccountViewModel {
let account: Account let account: Account
@Published var inLists: [Models.List] = [] var inLists: [Models.List] = []
@Published var isLoadingInfo: Bool = true var isLoadingInfo: Bool = true
var client: Client? var client: Client?

View file

@ -7,12 +7,12 @@ import SwiftUI
public struct ListEditView: View { public struct ListEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@StateObject private var viewModel: ListEditViewModel @State private var viewModel: ListEditViewModel
public init(list: Models.List) { public init(list: Models.List) {
_viewModel = StateObject(wrappedValue: .init(list: list)) _viewModel = .init(initialValue: .init(list: list))
} }
public var body: some View { public var body: some View {

View file

@ -1,16 +1,17 @@
import Combine import Combine
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class ListEditViewModel: ObservableObject { @Observable public class ListEditViewModel {
let list: Models.List let list: Models.List
var client: Client? var client: Client?
@Published var isLoadingAccounts: Bool = true var isLoadingAccounts: Bool = true
@Published var accounts: [Account] = [] var accounts: [Account] = []
init(list: Models.List) { init(list: Models.List) {
self.list = list self.list = list

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Models", name: "Models",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Network", name: "Network",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -1,10 +1,11 @@
import Combine import Combine
import Foundation import Foundation
import Models import Models
import Observation
import os import os
import SwiftUI import SwiftUI
public final class Client: ObservableObject, Equatable, Identifiable, Hashable { @Observable public final class Client: Equatable, Identifiable, Hashable {
public static func == (lhs: Client, rhs: Client) -> Bool { public static func == (lhs: Client, rhs: Client) -> Bool {
let lhsToken = lhs.critical.withLock { $0.oauthToken } let lhsToken = lhs.critical.withLock { $0.oauthToken }
let rhsToken = rhs.critical.withLock { $0.oauthToken } let rhsToken = rhs.critical.withLock { $0.oauthToken }

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Notifications", name: "Notifications",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -149,16 +149,16 @@ struct NotificationRowView: View {
if let status = notification.status { if let status = notification.status {
HStack { HStack {
if type == .mention { if type == .mention {
StatusRowView(viewModel: { .init(status: status, StatusRowView(viewModel: .init(status: status,
client: client, client: client,
routerPath: routerPath, routerPath: routerPath,
showActions: true) }) showActions: true))
} else { } else {
StatusRowView(viewModel: { .init(status: status, StatusRowView(viewModel: .init(status: status,
client: client, client: client,
routerPath: routerPath, routerPath: routerPath,
showActions: false, showActions: false,
textDisabled: true) }) textDisabled: true))
.lineLimit(4) .lineLimit(4)
} }
Spacer() Spacer()

View file

@ -8,11 +8,11 @@ import SwiftUI
public struct NotificationsListView: View { public struct NotificationsListView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var account: CurrentAccount @Environment(CurrentAccount.self) private var account
@StateObject private var viewModel = NotificationsViewModel() @State private var viewModel = NotificationsViewModel()
let lockedType: Models.Notification.NotificationType? let lockedType: Models.Notification.NotificationType?
@ -88,13 +88,13 @@ public struct NotificationsListView: View {
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
SoundEffectManager.shared.playSound(of: .refresh) SoundEffectManager.shared.playSound(of: .refresh)
} }
.onChange(of: watcher.latestEvent?.id, perform: { _ in .onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent { if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent) viewModel.handleEvent(event: latestEvent)
} }
}) }
.onChange(of: scenePhase, perform: { scenePhase in .onChange(of: scenePhase) { _, newValue in
switch scenePhase { switch newValue {
case .active: case .active:
Task { Task {
await viewModel.fetchNotifications() await viewModel.fetchNotifications()
@ -102,7 +102,7 @@ public struct NotificationsListView: View {
default: default:
break break
} }
}) }
} }
@ViewBuilder @ViewBuilder

View file

@ -2,10 +2,11 @@ import Env
import Foundation import Foundation
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class NotificationsViewModel: ObservableObject { @Observable class NotificationsViewModel {
public enum State { public enum State {
public enum PagingState { public enum PagingState {
case none, hasNextPage, loadingNextPage case none, hasNextPage, loadingNextPage
@ -35,8 +36,8 @@ class NotificationsViewModel: ObservableObject {
var currentAccount: CurrentAccount? var currentAccount: CurrentAccount?
@Published var state: State = .loading var state: State = .loading
@Published var selectedType: Models.Notification.NotificationType? { var selectedType: Models.Notification.NotificationType? {
didSet { didSet {
if oldValue != selectedType { if oldValue != selectedType {
consolidatedNotifications = [] consolidatedNotifications = []

View file

@ -7,7 +7,7 @@ let package = Package(
name: "Status", name: "Status",
defaultLocalization: "en", defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v17),
], ],
products: [ products: [
.library( .library(

View file

@ -7,12 +7,12 @@ import SwiftUI
public struct StatusDetailView: View { public struct StatusDetailView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var watcher: StreamWatcher @Environment(StreamWatcher.self) private var watcher
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@StateObject private var viewModel: StatusDetailViewModel @State private var viewModel: StatusDetailViewModel
@State private var isLoaded: Bool = false @State private var isLoaded: Bool = false
@State private var statusHeight: CGFloat = 0 @State private var statusHeight: CGFloat = 0
@ -22,15 +22,15 @@ public struct StatusDetailView: View {
@AccessibilityFocusState private var initialFocusBugWorkaround: Bool @AccessibilityFocusState private var initialFocusBugWorkaround: Bool
public init(statusId: String) { public init(statusId: String) {
_viewModel = StateObject(wrappedValue: { .init(statusId: statusId) }()) _viewModel = .init(wrappedValue: .init(statusId: statusId))
} }
public init(status: Status) { public init(status: Status) {
_viewModel = StateObject(wrappedValue: { .init(status: status) }()) _viewModel = .init(wrappedValue: .init(status: status))
} }
public init(remoteStatusURL: URL) { public init(remoteStatusURL: URL) {
_viewModel = StateObject(wrappedValue: { .init(remoteStatusURL: remoteStatusURL) }()) _viewModel = .init(wrappedValue: .init(remoteStatusURL: remoteStatusURL))
} }
public var body: some View { public var body: some View {
@ -45,9 +45,8 @@ public struct StatusDetailView: View {
case .loading: case .loading:
loadingDetailView loadingDetailView
case let .display(statuses, date): case let .display(statuses):
makeStatusesListView(statuses: statuses, date: date) makeStatusesListView(statuses: statuses)
.id(date)
if !isLoaded { if !isLoaded {
loadingContextView loadingContextView
@ -69,12 +68,12 @@ public struct StatusDetailView: View {
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
.onChange(of: viewModel.scrollToId, perform: { scrollToId in .onChange(of: viewModel.scrollToId) { _, newValue in
if let scrollToId { if let newValue {
viewModel.scrollToId = nil viewModel.scrollToId = nil
proxy.scrollTo(scrollToId, anchor: .top) proxy.scrollTo(newValue, anchor: .top)
} }
}) }
.task { .task {
guard !isLoaded else { return } guard !isLoaded else { return }
viewModel.client = client viewModel.client = client
@ -92,7 +91,7 @@ public struct StatusDetailView: View {
} }
} }
} }
.onChange(of: watcher.latestEvent?.id) { _ in .onChange(of: watcher.latestEvent?.id) {
guard let lastEvent = watcher.latestEvent else { return } guard let lastEvent = watcher.latestEvent else { return }
viewModel.handleEvent(event: lastEvent, currentAccount: currentAccount.account) viewModel.handleEvent(event: lastEvent, currentAccount: currentAccount.account)
} }
@ -101,69 +100,35 @@ public struct StatusDetailView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
private func makeStatusesListView(statuses: [Status], date _: Date) -> some View { private func makeStatusesListView(statuses: [Status]) -> some View {
ForEach(statuses) { status in ForEach(statuses) { status in
var isReplyToPrevious: Bool = false let isReplyToPrevious = viewModel.isReplyToPreviousCache[status.id] ?? false
if let index = statuses.firstIndex(where: { $0.id == status.id }),
index > 0,
statuses[index - 1].id == status.inReplyToId
{
if index == 1, statuses.count > 2 {
let nextStatus = statuses[2]
isReplyToPrevious = nextStatus.inReplyToId == status.id
} else if statuses.count == 2 {
isReplyToPrevious = false
} else {
isReplyToPrevious = true
}
}
let viewModel: StatusRowViewModel = .init(status: status, let viewModel: StatusRowViewModel = .init(status: status,
client: client, client: client,
routerPath: routerPath) routerPath: routerPath)
return HStack(spacing: 0) { let isFocused = self.viewModel.statusId == status.id
if isReplyToPrevious {
Rectangle()
.fill(theme.tintColor)
.frame(width: 2)
.accessibilityHidden(true)
Spacer(minLength: 8)
}
if self.viewModel.statusId == status.id {
makeCurrentStatusView(status: status)
.environment(\.extraLeadingInset, isReplyToPrevious ? 10 : 0)
} else {
StatusRowView(viewModel: { viewModel })
.environment(\.extraLeadingInset, isReplyToPrevious ? 10 : 0)
}
}
.listRowBackground(viewModel.highlightRowColor)
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
}
}
private func makeCurrentStatusView(status: Status) -> some View { StatusRowView(viewModel: viewModel)
StatusRowView(viewModel: { .init(status: status, .environment(\.extraLeadingInset, isReplyToPrevious ? 10 : 0)
client: client, .environment(\.isStatusReplyToPrevious, isReplyToPrevious)
routerPath: routerPath) }) .environment(\.isStatusFocused, isFocused)
.environment(\.isStatusFocused, true) .overlay {
.environment(\.isStatusDetailLoaded, !viewModel.isLoadingContext) if isFocused {
.accessibilityFocused($initialFocusBugWorkaround, equals: true) GeometryReader { reader in
.overlay { VStack {}
GeometryReader { reader in .onAppear {
VStack {} statusHeight = reader.size.height
.onAppear { }
statusHeight = reader.size.height
} }
}
} }
} .id(status.id)
.id(status.id) .listRowBackground(viewModel.highlightRowColor)
// VoiceOver / Switch Control focus workaround .listRowInsets(.init(top: 12,
.onAppear { leading: .layoutPadding,
initialFocusBugWorkaround = true bottom: 12,
} trailing: .layoutPadding))
}
} }
private var errorView: some View { private var errorView: some View {
@ -181,7 +146,7 @@ public struct StatusDetailView: View {
private var loadingDetailView: some View { private var loadingDetailView: some View {
ForEach(Status.placeholders()) { status in ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
} }
} }

View file

@ -5,7 +5,7 @@ import Network
import SwiftUI import SwiftUI
@MainActor @MainActor
class StatusDetailViewModel: ObservableObject { @Observable class StatusDetailViewModel {
public var statusId: String? public var statusId: String?
public var remoteStatusURL: URL? public var remoteStatusURL: URL?
@ -13,13 +13,15 @@ class StatusDetailViewModel: ObservableObject {
var routerPath: RouterPath? var routerPath: RouterPath?
enum State { enum State {
case loading, display(statuses: [Status], date: Date), error(error: Error) case loading, display(statuses: [Status]), error(error: Error)
} }
@Published var state: State = .loading var state: State = .loading
@Published var isLoadingContext = true var title: LocalizedStringKey = ""
@Published var title: LocalizedStringKey = "" var scrollToId: String?
@Published var scrollToId: String?
@ObservationIgnored
var isReplyToPreviousCache: [String: Bool] = [:]
init(statusId: String) { init(statusId: String) {
state = .loading state = .loading
@ -28,7 +30,7 @@ class StatusDetailViewModel: ObservableObject {
} }
init(status: Status) { init(status: Status) {
state = .display(statuses: [status], date: Date()) state = .display(statuses: [status])
title = "status.post-from-\(status.account.displayNameWithoutEmojis)" title = "status.post-from-\(status.account.displayNameWithoutEmojis)"
statusId = status.id statusId = status.id
remoteStatusURL = nil remoteStatusURL = nil
@ -74,23 +76,20 @@ class StatusDetailViewModel: ObservableObject {
private func fetchStatusDetail(animate: Bool) async { private func fetchStatusDetail(animate: Bool) async {
guard let client, let statusId else { return } guard let client, let statusId else { return }
do { do {
isLoadingContext = true
let data = try await fetchContextData(client: client, statusId: statusId) let data = try await fetchContextData(client: client, statusId: statusId)
title = "status.post-from-\(data.status.account.displayNameWithoutEmojis)" title = "status.post-from-\(data.status.account.displayNameWithoutEmojis)"
var statuses = data.context.ancestors var statuses = data.context.ancestors
statuses.append(data.status) statuses.append(data.status)
statuses.append(contentsOf: data.context.descendants) statuses.append(contentsOf: data.context.descendants)
cacheReplyTopPrevious(statuses: statuses)
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
if animate { if animate {
withAnimation { withAnimation {
isLoadingContext = false state = .display(statuses: statuses)
state = .display(statuses: statuses, date: Date())
} }
} else { } else {
isLoadingContext = false state = .display(statuses: statuses)
state = .display(statuses: statuses, date: Date())
scrollToId = statusId scrollToId = statusId
} }
} catch { } catch {
@ -108,6 +107,27 @@ class StatusDetailViewModel: ObservableObject {
return try await .init(status: status, context: context) return try await .init(status: status, context: context)
} }
private func cacheReplyTopPrevious(statuses: [Status]) {
isReplyToPreviousCache = [:]
for status in statuses {
var isReplyToPrevious: Bool = false
if let index = statuses.firstIndex(where: { $0.id == status.id }),
index > 0,
statuses[index - 1].id == status.inReplyToId
{
if index == 1, statuses.count > 2 {
let nextStatus = statuses[2]
isReplyToPrevious = nextStatus.inReplyToId == status.id
} else if statuses.count == 2 {
isReplyToPrevious = false
} else {
isReplyToPrevious = true
}
}
isReplyToPreviousCache[status.id] = isReplyToPrevious
}
}
func handleEvent(event: any StreamEvent, currentAccount: Account?) { func handleEvent(event: any StreamEvent, currentAccount: Account?) {
Task { Task {
if let event = event as? StreamEventUpdate, if let event = event as? StreamEventUpdate,

View file

@ -8,11 +8,11 @@ import SwiftUI
struct StatusEditorAccessoryView: View { struct StatusEditorAccessoryView: View {
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@FocusState<Bool>.Binding var isSpoilerTextFocused: Bool @FocusState<Bool>.Binding var isSpoilerTextFocused: Bool
@ObservedObject var viewModel: StatusEditorViewModel var viewModel: StatusEditorViewModel
@State private var isDraftsSheetDisplayed: Bool = false @State private var isDraftsSheetDisplayed: Bool = false
@State private var isLanguageSheetDisplayed: Bool = false @State private var isLanguageSheetDisplayed: Bool = false
@ -24,6 +24,7 @@ struct StatusEditorAccessoryView: View {
@State private var isCameraPickerPresented: Bool = false @State private var isCameraPickerPresented: Bool = false
var body: some View { var body: some View {
@Bindable var viewModel = viewModel
VStack(spacing: 0) { VStack(spacing: 0) {
Divider() Divider()
HStack { HStack {

View file

@ -5,7 +5,7 @@ import SwiftUI
struct StatusEditorAutoCompleteView: View { struct StatusEditorAutoCompleteView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ObservedObject var viewModel: StatusEditorViewModel var viewModel: StatusEditorViewModel
var body: some View { var body: some View {
if !viewModel.mentionsSuggestions.isEmpty || !viewModel.tagsSuggestions.isEmpty { if !viewModel.mentionsSuggestions.isEmpty || !viewModel.tagsSuggestions.isEmpty {

View file

@ -7,8 +7,8 @@ import SwiftUI
struct StatusEditorMediaEditView: View { struct StatusEditorMediaEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@ObservedObject var viewModel: StatusEditorViewModel var viewModel: StatusEditorViewModel
let container: StatusEditorMediaContainer let container: StatusEditorMediaContainer
@State private var imageDescription: String = "" @State private var imageDescription: String = ""

View file

@ -7,8 +7,8 @@ import SwiftUI
struct StatusEditorMediaView: View { struct StatusEditorMediaView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@ObservedObject var viewModel: StatusEditorViewModel var viewModel: StatusEditorViewModel
@State private var editingContainer: StatusEditorMediaContainer? @State private var editingContainer: StatusEditorMediaContainer?
@State private var isErrorDisplayed: Bool = false @State private var isErrorDisplayed: Bool = false

View file

@ -12,15 +12,15 @@ struct StatusEditorPollView: View {
@State private var currentFocusIndex: Int = 0 @State private var currentFocusIndex: Int = 0
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@ObservedObject var viewModel: StatusEditorViewModel var viewModel: StatusEditorViewModel
@Binding var showPoll: Bool @Binding var showPoll: Bool
var body: some View { var body: some View {
@Bindable var viewModel = viewModel
let count = viewModel.pollOptions.count let count = viewModel.pollOptions.count
VStack { VStack {
ForEach(0 ..< count, id: \.self) { index in ForEach(0 ..< count, id: \.self) { index in
VStack { VStack {
@ -38,10 +38,6 @@ struct StatusEditorPollView: View {
addChoice(at: index) addChoice(at: index)
} }
} }
.onChange(of: viewModel.pollOptions[index]) {
let maxCharacters: Int = currentInstance.instance?.configuration?.polls.maxCharactersPerOption ?? 50
viewModel.pollOptions[index] = String($0.prefix(maxCharacters))
}
if canAddMoreAt(index) { if canAddMoreAt(index) {
Button { Button {

View file

@ -12,22 +12,22 @@ import SwiftUI
import UIKit import UIKit
public struct StatusEditorView: View { public struct StatusEditorView: View {
@EnvironmentObject private var appAccounts: AppAccountsManager @Environment(AppAccountsManager.self) private var appAccounts
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@EnvironmentObject private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: StatusEditorViewModel @State private var viewModel: StatusEditorViewModel
@FocusState private var isSpoilerTextFocused: Bool @FocusState private var isSpoilerTextFocused: Bool
@State private var isDismissAlertPresented: Bool = false @State private var isDismissAlertPresented: Bool = false
@State private var isLanguageConfirmPresented = false @State private var isLanguageConfirmPresented = false
public init(mode: StatusEditorViewModel.Mode) { public init(mode: StatusEditorViewModel.Mode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode)) _viewModel = .init(initialValue: .init(mode: mode))
} }
public var body: some View { public var body: some View {
@ -93,9 +93,9 @@ public struct StatusEditorView: View {
await viewModel.fetchCustomEmojis() await viewModel.fetchCustomEmojis()
} }
} }
.onChange(of: currentAccount.account?.id, perform: { _ in .onChange(of: currentAccount.account?.id) {
viewModel.currentAccount = currentAccount.account viewModel.currentAccount = currentAccount.account
}) }
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
.navigationTitle(viewModel.mode.title) .navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -164,10 +164,10 @@ public struct StatusEditorView: View {
} }
} }
.interactiveDismissDisabled(viewModel.shouldDisplayDismissWarning) .interactiveDismissDisabled(viewModel.shouldDisplayDismissWarning)
.onChange(of: appAccounts.currentClient) { newClient in .onChange(of: appAccounts.currentClient) { _, newValue in
if viewModel.mode.isInShareExtension { if viewModel.mode.isInShareExtension {
currentAccount.setClient(client: newClient) currentAccount.setClient(client: newValue)
viewModel.client = newClient viewModel.client = newValue
} }
} }
} }

View file

@ -8,7 +8,7 @@ import PhotosUI
import SwiftUI import SwiftUI
@MainActor @MainActor
public class StatusEditorViewModel: NSObject, ObservableObject { @Observable public class StatusEditorViewModel: NSObject {
var mode: Mode var mode: Mode
var client: Client? var client: Client?
@ -50,7 +50,7 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
return textView.markedTextRange return textView.markedTextRange
} }
@Published var statusText = NSMutableAttributedString(string: "") { var statusText = NSMutableAttributedString(string: "") {
didSet { didSet {
let range = selectedRange let range = selectedRange
processText() processText()
@ -73,18 +73,18 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
private var itemsProvider: [NSItemProvider]? private var itemsProvider: [NSItemProvider]?
@Published var backupStatusText: NSAttributedString? var backupStatusText: NSAttributedString?
@Published var showPoll: Bool = false var showPoll: Bool = false
@Published var pollVotingFrequency = PollVotingFrequency.oneVote var pollVotingFrequency = PollVotingFrequency.oneVote
@Published var pollDuration = Duration.oneDay var pollDuration = Duration.oneDay
@Published var pollOptions: [String] = ["", ""] var pollOptions: [String] = ["", ""]
@Published var spoilerOn: Bool = false var spoilerOn: Bool = false
@Published var spoilerText: String = "" var spoilerText: String = ""
@Published var isPosting: Bool = false var isPosting: Bool = false
@Published var selectedMedias: [PhotosPickerItem] = [] { var selectedMedias: [PhotosPickerItem] = [] {
didSet { didSet {
if selectedMedias.count > 4 { if selectedMedias.count > 4 {
selectedMedias = selectedMedias.prefix(4).map { $0 } selectedMedias = selectedMedias.prefix(4).map { $0 }
@ -94,16 +94,16 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
} }
} }
@Published var isMediasLoading: Bool = false var isMediasLoading: Bool = false
@Published var mediasImages: [StatusEditorMediaContainer] = [] var mediasImages: [StatusEditorMediaContainer] = []
@Published var replyToStatus: Status? var replyToStatus: Status?
@Published var embeddedStatus: Status? var embeddedStatus: Status?
@Published var customEmojis: [Emoji] = [] var customEmojis: [Emoji] = []
@Published var postingError: String? var postingError: String?
@Published var showPostingErrorAlert: Bool = false var showPostingErrorAlert: Bool = false
var canPost: Bool { var canPost: Bool {
statusText.length > 0 || !mediasImages.isEmpty statusText.length > 0 || !mediasImages.isEmpty
@ -123,11 +123,11 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
return !modifiedStatusText.isEmpty && !mode.isInShareExtension return !modifiedStatusText.isEmpty && !mode.isInShareExtension
} }
@Published var visibility: Models.Visibility = .pub var visibility: Models.Visibility = .pub
@Published var mentionsSuggestions: [Account] = [] var mentionsSuggestions: [Account] = []
@Published var tagsSuggestions: [Tag] = [] var tagsSuggestions: [Tag] = []
@Published var selectedLanguage: String? var selectedLanguage: String?
var hasExplicitlySelectedLanguage: Bool = false var hasExplicitlySelectedLanguage: Bool = false
private var currentSuggestionRange: NSRange? private var currentSuggestionRange: NSRange?

View file

@ -23,10 +23,10 @@ public struct StatusEmbeddedView: View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
makeAccountView(account: status.reblog?.account ?? status.account) makeAccountView(account: status.reblog?.account ?? status.account)
StatusRowView(viewModel: { .init(status: status, StatusRowView(viewModel: .init(status: status,
client: client, client: client,
routerPath: routerPath, routerPath: routerPath,
showActions: false) }) showActions: false))
.accessibilityLabel(status.content.asRawText) .accessibilityLabel(status.content.asRawText)
.environment(\.isCompact, true) .environment(\.isCompact, true)
} }

View file

@ -6,7 +6,7 @@ import SwiftUI
public struct StatusEditHistoryView: View { public struct StatusEditHistoryView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
private let statusId: String private let statusId: String

View file

@ -1,5 +1,6 @@
import Combine import Combine
import Models import Models
import Observation
import SwiftUI import SwiftUI
public enum StatusesState { public enum StatusesState {
@ -13,7 +14,7 @@ public enum StatusesState {
} }
@MainActor @MainActor
public protocol StatusesFetcher: ObservableObject { public protocol StatusesFetcher {
var statusesState: StatusesState { get } var statusesState: StatusesState { get }
func fetchNewestStatuses() async func fetchNewestStatuses() async
func fetchNextPage() async func fetchNextPage() async

View file

@ -8,7 +8,7 @@ import SwiftUI
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher { public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ObservedObject private var fetcher: Fetcher @State private var fetcher: Fetcher
// Whether this status is on a remote local timeline (many actions are unavailable if so) // Whether this status is on a remote local timeline (many actions are unavailable if so)
private let isRemote: Bool private let isRemote: Bool
private let routerPath: RouterPath private let routerPath: RouterPath
@ -19,7 +19,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
routerPath: RouterPath, routerPath: RouterPath,
isRemote: Bool = false) isRemote: Bool = false)
{ {
self.fetcher = fetcher _fetcher = .init(initialValue: fetcher)
self.isRemote = isRemote self.isRemote = isRemote
self.client = client self.client = client
self.routerPath = routerPath self.routerPath = routerPath
@ -29,7 +29,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
switch fetcher.statusesState { switch fetcher.statusesState {
case .loading: case .loading:
ForEach(Status.placeholders()) { status in ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) }) StatusRowView(viewModel: .init(status: status, client: client, routerPath: routerPath))
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
} }
case .error: case .error:
@ -46,12 +46,10 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
case let .display(statuses, nextPageState): case let .display(statuses, nextPageState):
ForEach(statuses, id: \.viewId) { status in ForEach(statuses, id: \.viewId) { status in
StatusRowView(viewModel: { StatusRowViewModel(status: status, StatusRowView(viewModel: StatusRowViewModel(status: status,
client: client, client: client,
routerPath: routerPath, routerPath: routerPath,
isRemote: isRemote) isRemote: isRemote))
})
.id(status.id) .id(status.id)
.onAppear { .onAppear {
fetcher.statusDidAppear(status: status) fetcher.statusDidAppear(status: status)

View file

@ -1,11 +1,12 @@
import AVKit import AVKit
import DesignSystem import DesignSystem
import Env import Env
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
class VideoPlayerViewModel: ObservableObject { @Observable class VideoPlayerViewModel {
@Published var player: AVPlayer? var player: AVPlayer?
private let url: URL private let url: URL
init(url: URL) { init(url: URL) {
@ -53,7 +54,7 @@ struct VideoPlayerView: View {
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject var viewModel: VideoPlayerViewModel @State var viewModel: VideoPlayerViewModel
var body: some View { var body: some View {
ZStack { ZStack {
@ -75,8 +76,8 @@ struct VideoPlayerView: View {
viewModel.pause() viewModel.pause()
} }
.cornerRadius(4) .cornerRadius(4)
.onChange(of: scenePhase, perform: { scenePhase in .onChange(of: scenePhase) { _, newValue in
switch scenePhase { switch newValue {
case .background, .inactive: case .background, .inactive:
viewModel.pause() viewModel.pause()
case .active: case .active:
@ -86,6 +87,6 @@ struct VideoPlayerView: View {
default: default:
break break
} }
}) }
} }
} }

View file

@ -6,15 +6,16 @@ import SwiftUI
public struct StatusPollView: View { public struct StatusPollView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @Environment(Client.self) private var client
@EnvironmentObject private var currentInstance: CurrentInstance @Environment(CurrentInstance.self) private var currentInstance
@EnvironmentObject private var currentAccount: CurrentAccount @Environment(CurrentAccount.self) private var currentAccount
@StateObject private var viewModel: StatusPollViewModel
@State private var viewModel: StatusPollViewModel
private var status: AnyStatus private var status: AnyStatus
public init(poll: Poll, status: AnyStatus) { public init(poll: Poll, status: AnyStatus) {
_viewModel = StateObject(wrappedValue: .init(poll: poll)) _viewModel = .init(initialValue: .init(poll: poll))
self.status = status self.status = status
} }

View file

@ -1,15 +1,16 @@
import Combine import Combine
import Models import Models
import Network import Network
import Observation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class StatusPollViewModel: ObservableObject { @Observable public class StatusPollViewModel {
public var client: Client? public var client: Client?
public var instance: Instance? public var instance: Instance?
@Published var poll: Poll var poll: Poll
@Published var votes: [Int] = [] var votes: [Int] = []
var showResults: Bool { var showResults: Bool {
poll.ownVotes?.isEmpty == false || poll.expired poll.ownVotes?.isEmpty == false || poll.expired

View file

@ -28,8 +28,8 @@ struct StatusActionButtonStyle: ButtonStyle {
SparklesView(counter: sparklesCounter, tint: tint, size: 5, velocity: 30) SparklesView(counter: sparklesCounter, tint: tint, size: 5, velocity: 30)
} }
} }
.onChange(of: configuration.isPressed) { isPressed in .onChange(of: configuration.isPressed) { _, newValue in
guard tintColor != nil, !isPressed, !isOn else { return } guard tintColor != nil, !newValue, !isOn else { return }
withAnimation(.spring(response: 1, dampingFraction: 1)) { withAnimation(.spring(response: 1, dampingFraction: 1)) {
sparklesCounter += 1 sparklesCounter += 1
@ -88,8 +88,8 @@ struct StatusActionButtonStyle: ButtonStyle {
.onAppear { .onAppear {
cells = Self.generateCells() cells = Self.generateCells()
} }
.onChange(of: counter) { [counter] newCounter in .onChange(of: counter) { oldValue, newValue in
if floor(counter) != floor(newCounter) { if floor(oldValue) != floor(newValue) {
cells = Self.generateCells() cells = Self.generateCells()
} }
} }

Some files were not shown because too many files have changed in this diff Show more