diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index bb886799..f0111ee7 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -17,9 +17,9 @@ extension View { navigationDestination(for: RouterDestination.self) { destination in switch destination { case let .accountDetail(id): - AccountDetailView(accountId: id) + AccountDetailView(accountId: id, scrollToTopSignal: .constant(0)) case let .accountDetailWithAccount(account): - AccountDetailView(account: account) + AccountDetailView(account: account, scrollToTopSignal: .constant(0)) case let .accountSettingsWithAccount(account, appAccount): AccountSettingsView(account: account, appAccount: appAccount) case let .statusDetail(id): diff --git a/IceCubesApp/App/Tabs/ExploreTab.swift b/IceCubesApp/App/Tabs/ExploreTab.swift index 722884bb..75c4cf88 100644 --- a/IceCubesApp/App/Tabs/ExploreTab.swift +++ b/IceCubesApp/App/Tabs/ExploreTab.swift @@ -14,11 +14,12 @@ struct ExploreTab: View { @Environment(CurrentAccount.self) private var currentAccount @Environment(Client.self) private var client @State private var routerPath = RouterPath() + @State private var scrollToTopSignal: Int = 0 @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routerPath.path) { - ExploreView() + ExploreView(scrollToTopSignal: $scrollToTopSignal) .withAppRouter() .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar) @@ -39,7 +40,11 @@ struct ExploreTab: View { .environment(routerPath) .onChange(of: $popToRootTab.wrappedValue) { _, newValue in if newValue == .explore { - routerPath.path = [] + if routerPath.path.isEmpty { + scrollToTopSignal += 1 + } else { + routerPath.path = [] + } } } .onChange(of: client.id) { diff --git a/IceCubesApp/App/Tabs/MessagesTab.swift b/IceCubesApp/App/Tabs/MessagesTab.swift index 8aca6224..d7740951 100644 --- a/IceCubesApp/App/Tabs/MessagesTab.swift +++ b/IceCubesApp/App/Tabs/MessagesTab.swift @@ -16,11 +16,12 @@ struct MessagesTab: View { @Environment(CurrentAccount.self) private var currentAccount @Environment(AppAccountsManager.self) private var appAccount @State private var routerPath = RouterPath() + @State private var scrollToTopSignal: Int = 0 @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routerPath.path) { - ConversationsListView() + ConversationsListView(scrollToTopSignal: $scrollToTopSignal) .withAppRouter() .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .toolbar { @@ -35,7 +36,11 @@ struct MessagesTab: View { } .onChange(of: $popToRootTab.wrappedValue) { _, newValue in if newValue == .messages { - routerPath.path = [] + if routerPath.path.isEmpty { + scrollToTopSignal += 1 + } else { + routerPath.path = [] + } } } .onChange(of: client.id) { diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index ad939714..4dc5d236 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -20,13 +20,14 @@ struct NotificationsTab: View { @Environment(UserPreferences.self) private var userPreferences @Environment(PushNotificationsService.self) private var pushNotificationsService @State private var routerPath = RouterPath() + @State private var scrollToTopSignal: Int = 0 @Binding var popToRootTab: Tab let lockedType: Models.Notification.NotificationType? var body: some View { NavigationStack(path: $routerPath.path) { - NotificationsListView(lockedType: lockedType) + NotificationsListView(lockedType: lockedType, scrollToTopSignal: $scrollToTopSignal) .withAppRouter() .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .toolbar { @@ -58,7 +59,11 @@ struct NotificationsTab: View { .environment(routerPath) .onChange(of: $popToRootTab.wrappedValue) { _, newValue in if newValue == .notifications { - routerPath.path = [] + if routerPath.path.isEmpty { + scrollToTopSignal += 1 + } else { + routerPath.path = [] + } } } .onChange(of: pushNotificationsService.handledNotification) { _, newValue in diff --git a/IceCubesApp/App/Tabs/ProfileTab.swift b/IceCubesApp/App/Tabs/ProfileTab.swift index 253f7432..421e8ef6 100644 --- a/IceCubesApp/App/Tabs/ProfileTab.swift +++ b/IceCubesApp/App/Tabs/ProfileTab.swift @@ -15,25 +15,30 @@ struct ProfileTab: View { @Environment(Client.self) private var client @Environment(CurrentAccount.self) private var currentAccount @State private var routerPath = RouterPath() + @State private var scrollToTopSignal: Int = 0 @Binding var popToRootTab: Tab var body: some View { NavigationStack(path: $routerPath.path) { if let account = currentAccount.account { - AccountDetailView(account: account) + AccountDetailView(account: account, scrollToTopSignal: $scrollToTopSignal) .withAppRouter() .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar) .id(account.id) } else { - AccountDetailView(account: .placeholder()) + AccountDetailView(account: .placeholder(), scrollToTopSignal: $scrollToTopSignal) .redacted(reason: .placeholder) .allowsHitTesting(false) } } .onChange(of: $popToRootTab.wrappedValue) { _, newValue in if newValue == .profile { - routerPath.path = [] + if routerPath.path.isEmpty { + scrollToTopSignal += 1 + } else { + routerPath.path = [] + } } } .onChange(of: client.id) { diff --git a/Packages/Account/Sources/Account/AccountDetailContextMenu.swift b/Packages/Account/Sources/Account/AccountDetailContextMenu.swift index dae980ba..aec72dbb 100644 --- a/Packages/Account/Sources/Account/AccountDetailContextMenu.swift +++ b/Packages/Account/Sources/Account/AccountDetailContextMenu.swift @@ -146,8 +146,7 @@ public struct AccountDetailContextMenu: View { Divider() } - if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier - { + if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier { Button { Task { await viewModel.translate(userLang: lang) diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index c7aa10ff..830d3b84 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -30,14 +30,18 @@ public struct AccountDetailView: View { @State private var isEditingFilters: Bool = false @State private var isEditingRelationshipNote: Bool = false + @Binding var scrollToTopSignal: Int + /// When coming from a URL like a mention tap in a status. - public init(accountId: String) { + public init(accountId: String, scrollToTopSignal: Binding) { _viewModel = .init(initialValue: .init(accountId: accountId)) + _scrollToTopSignal = scrollToTopSignal } /// When the account is already fetched by the parent caller. - public init(account: Account) { + public init(account: Account, scrollToTopSignal: Binding) { _viewModel = .init(initialValue: .init(account: account)) + _scrollToTopSignal = scrollToTopSignal } public var body: some View { @@ -46,6 +50,7 @@ public struct AccountDetailView: View { makeHeaderView(proxy: proxy) .applyAccountDetailsRowStyle(theme: theme) .padding(.bottom, -20) + .id(ScrollToView.Constants.scrollToTop) familiarFollowers .applyAccountDetailsRowStyle(theme: theme) featuredTagsView @@ -83,6 +88,11 @@ public struct AccountDetailView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .background(theme.primaryBackgroundColor) + .onChange(of: scrollToTopSignal) { + withAnimation { + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) + } + } } .onAppear { guard reasons != .placeholder else { return } @@ -418,6 +428,6 @@ extension View { struct AccountDetailView_Previews: PreviewProvider { static var previews: some View { - AccountDetailView(account: .placeholder()) + AccountDetailView(account: .placeholder(), scrollToTopSignal: .constant(0)) } } diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 52d2a932..a8c73f35 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -96,6 +96,8 @@ import SwiftUI } } + var scrollToTopVisible: Bool = false + var translation: Translation? var isLoadingTranslation = false diff --git a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift index 32407fad..b55329da 100644 --- a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift +++ b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift @@ -96,9 +96,9 @@ public struct AppAccountsSelectorView: View { private var accountBackgroundColor: Color { if #available(iOS 16.4, *) { - return Color.clear + Color.clear } else { - return theme.secondaryBackgroundColor + theme.secondaryBackgroundColor } } diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift index 19d23da4..cf007b73 100644 --- a/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift @@ -15,7 +15,11 @@ public struct ConversationsListView: View { @State private var viewModel = ConversationsListViewModel() - public init() {} + @Binding var scrollToTopSignal: Int + + public init(scrollToTopSignal: Binding) { + _scrollToTopSignal = scrollToTopSignal + } private var conversations: Binding<[Conversation]> { if viewModel.isLoadingFirstPage { @@ -26,88 +30,107 @@ public struct ConversationsListView: View { } public var body: some View { - ScrollView { - LazyVStack { - Group { - if !conversations.isEmpty || viewModel.isLoadingFirstPage { - ForEach(conversations) { $conversation in - if viewModel.isLoadingFirstPage { - ConversationsListRow(conversation: $conversation, viewModel: viewModel) - .padding(.horizontal, .layoutPadding) - .redacted(reason: .placeholder) - .allowsHitTesting(false) - } else { - ConversationsListRow(conversation: $conversation, viewModel: viewModel) - .padding(.horizontal, .layoutPadding) + ScrollViewReader { proxy in + ScrollView { + scrollToTopView + LazyVStack { + Group { + if !conversations.isEmpty || viewModel.isLoadingFirstPage { + ForEach(conversations) { $conversation in + if viewModel.isLoadingFirstPage { + ConversationsListRow(conversation: $conversation, viewModel: viewModel) + .padding(.horizontal, .layoutPadding) + .redacted(reason: .placeholder) + .allowsHitTesting(false) + } else { + ConversationsListRow(conversation: $conversation, viewModel: viewModel) + .padding(.horizontal, .layoutPadding) + } + Divider() } - Divider() - } - } else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError { - EmptyView(iconName: "tray", - title: "conversations.empty.title", - message: "conversations.empty.message") - } else if viewModel.isError { - ErrorView(title: "conversations.error.title", - message: "conversations.error.message", - buttonTitle: "conversations.error.button") - { - Task { - await viewModel.fetchConversations() - } - } - } - - if viewModel.nextPage != nil { - HStack { - Spacer() - ProgressView() - Spacer() - } - .onAppear { - if !viewModel.isLoadingNextPage { + } else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError { + EmptyView(iconName: "tray", + title: "conversations.empty.title", + message: "conversations.empty.message") + } else if viewModel.isError { + ErrorView(title: "conversations.error.title", + message: "conversations.error.message", + buttonTitle: "conversations.error.button") + { Task { - await viewModel.fetchNextPage() + await viewModel.fetchConversations() + } + } + } + + if viewModel.nextPage != nil { + HStack { + Spacer() + ProgressView() + Spacer() + } + .onAppear { + if !viewModel.isLoadingNextPage { + Task { + await viewModel.fetchNextPage() + } } } } } } + .padding(.top, .layoutPadding) } - .padding(.top, .layoutPadding) - } - .scrollContentBackground(.hidden) - .background(theme.primaryBackgroundColor) - .navigationTitle("conversations.navigation-title") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - StatusEditorToolbarItem(visibility: .direct) - if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn { - SecondaryColumnToolbarItem() + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) + .navigationTitle("conversations.navigation-title") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + StatusEditorToolbarItem(visibility: .direct) + if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn { + SecondaryColumnToolbarItem() + } } - } - .onChange(of: watcher.latestEvent?.id) { - if let latestEvent = watcher.latestEvent { - viewModel.handleEvent(event: latestEvent) + .onChange(of: watcher.latestEvent?.id) { + if let latestEvent = watcher.latestEvent { + viewModel.handleEvent(event: latestEvent) + } } - } - .refreshable { - // note: this Task wrapper should not be necessary, but it reportedly crashes without it - // when refreshing on an empty list - Task { - SoundEffectManager.shared.playSound(of: .pull) - HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) - await viewModel.fetchConversations() - HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) - SoundEffectManager.shared.playSound(of: .refresh) + .onChange(of: scrollToTopSignal) { + withAnimation { + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) + } } - } - .onAppear { - viewModel.client = client - if client.isAuth { + .refreshable { + // note: this Task wrapper should not be necessary, but it reportedly crashes without it + // when refreshing on an empty list Task { + SoundEffectManager.shared.playSound(of: .pull) + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) await viewModel.fetchConversations() + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) + SoundEffectManager.shared.playSound(of: .refresh) + } + } + .onAppear { + viewModel.client = client + if client.isAuth { + Task { + await viewModel.fetchConversations() + } } } } } + + private var scrollToTopView: some View { + ScrollToView() + .frame(height: .scrollToViewHeight) + .onAppear { + viewModel.scrollToTopVisible = true + } + .onDisappear { + viewModel.scrollToTopVisible = false + } + } } diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift index 6f02f623..cfdc0e0f 100644 --- a/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift @@ -13,6 +13,8 @@ import SwiftUI var nextPage: LinkHandler? + var scrollToTopVisible: Bool = false + public init() {} func fetchConversations() async { diff --git a/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift b/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift index 9994c686..c5df1581 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift @@ -4,6 +4,7 @@ import Foundation public extension CGFloat { static let layoutPadding: CGFloat = 20 static let dividerPadding: CGFloat = 2 + static let scrollToViewHeight: CGFloat = 1 static let statusColumnsSpacing: CGFloat = 8 static let secondaryColumnWidth: CGFloat = 400 static let sidebarWidth: CGFloat = 80 diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollToView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollToView.swift new file mode 100644 index 00000000..81ec6ca1 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/ScrollToView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// Add to any `ScrollView` or `List` to enable scroll-to behaviour (e.g. useful for scroll-to-top). +/// +/// This view is configured such that `.onAppear` and `.onDisappear` are called while remaining invisible to users on-screen. +public struct ScrollToView: View { + public enum Constants { + public static let scrollToTop = "top" + } + + public init() {} + + public var body: some View { + HStack { SwiftUI.EmptyView() } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(.init()) + .accessibilityHidden(true) + .id(Constants.scrollToTop) + } +} diff --git a/Packages/Explore/Sources/Explore/ExploreView.swift b/Packages/Explore/Sources/Explore/ExploreView.swift index bc3fbebf..34b5b5df 100644 --- a/Packages/Explore/Sources/Explore/ExploreView.swift +++ b/Packages/Explore/Sources/Explore/ExploreView.swift @@ -15,86 +15,108 @@ public struct ExploreView: View { @State private var viewModel = ExploreViewModel() - public init() {} + @Binding var scrollToTopSignal: Int + + public init(scrollToTopSignal: Binding) { + _scrollToTopSignal = scrollToTopSignal + } public var body: some View { - List { - if !viewModel.isLoaded { - quickAccessView - loadingView - } else if !viewModel.searchQuery.isEmpty { - if let results = viewModel.results[viewModel.searchQuery] { - if results.isEmpty, !viewModel.isSearching { - EmptyView(iconName: "magnifyingglass", - title: "explore.search.empty.title", - message: "explore.search.empty.message") - .listRowBackground(theme.secondaryBackgroundColor) - .listRowSeparator(.hidden) + ScrollViewReader { proxy in + List { + scrollToTopView + .padding(.bottom, 4) + if !viewModel.isLoaded { + quickAccessView + .padding(.bottom, 5) + loadingView + } else if !viewModel.searchQuery.isEmpty { + if let results = viewModel.results[viewModel.searchQuery] { + if results.isEmpty, !viewModel.isSearching { + EmptyView(iconName: "magnifyingglass", + title: "explore.search.empty.title", + message: "explore.search.empty.message") + .listRowBackground(theme.secondaryBackgroundColor) + .listRowSeparator(.hidden) + } else { + makeSearchResultsView(results: results) + } } else { - makeSearchResultsView(results: results) + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(theme.secondaryBackgroundColor) + .listRowSeparator(.hidden) + .id(UUID()) } + } else if viewModel.allSectionsEmpty { + EmptyView(iconName: "magnifyingglass", + title: "explore.search.title", + message: "explore.search.message-\(client.server)") + .listRowBackground(theme.secondaryBackgroundColor) + .listRowSeparator(.hidden) } else { - HStack { - Spacer() - ProgressView() - Spacer() + quickAccessView + .padding(.bottom, 4) + + if !viewModel.trendingTags.isEmpty { + trendingTagsSection + } + if !viewModel.suggestedAccounts.isEmpty { + suggestedAccountsSection + } + if !viewModel.trendingStatuses.isEmpty { + trendingPostsSection + } + if !viewModel.trendingLinks.isEmpty { + trendingLinksSection } - .listRowBackground(theme.secondaryBackgroundColor) - .listRowSeparator(.hidden) - .id(UUID()) - } - } else if viewModel.allSectionsEmpty { - EmptyView(iconName: "magnifyingglass", - title: "explore.search.title", - message: "explore.search.message-\(client.server)") - .listRowBackground(theme.secondaryBackgroundColor) - .listRowSeparator(.hidden) - } else { - quickAccessView - if !viewModel.trendingTags.isEmpty { - trendingTagsSection - } - if !viewModel.suggestedAccounts.isEmpty { - suggestedAccountsSection - } - if !viewModel.trendingStatuses.isEmpty { - trendingPostsSection - } - if !viewModel.trendingLinks.isEmpty { - trendingLinksSection } } - } - .task { - viewModel.client = client - await viewModel.fetchTrending() - } - .refreshable { - Task { - SoundEffectManager.shared.playSound(of: .pull) - HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) + .environment(\.defaultMinListRowHeight, .scrollToViewHeight) + .task { + viewModel.client = client await viewModel.fetchTrending() - HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) - SoundEffectManager.shared.playSound(of: .refresh) } - } - .listStyle(.grouped) - .scrollContentBackground(.hidden) - .background(theme.secondaryBackgroundColor) - .navigationTitle("explore.navigation-title") - .searchable(text: $viewModel.searchQuery, - placement: .navigationBarDrawer(displayMode: .always), - prompt: Text("explore.search.prompt")) - .searchScopes($viewModel.searchScope) { - ForEach(ExploreViewModel.SearchScope.allCases, id: \.self) { scope in - Text(scope.localizedString) + .refreshable { + Task { + SoundEffectManager.shared.playSound(of: .pull) + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) + await viewModel.fetchTrending() + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) + SoundEffectManager.shared.playSound(of: .refresh) + } + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .navigationTitle("explore.navigation-title") + .searchable(text: $viewModel.searchQuery, + isPresented: $viewModel.isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text("explore.search.prompt")) + .searchScopes($viewModel.searchScope) { + ForEach(ExploreViewModel.SearchScope.allCases, id: \.self) { scope in + Text(scope.localizedString) + } + } + .task(id: viewModel.searchQuery) { + do { + try await Task.sleep(for: .milliseconds(150)) + await viewModel.search() + } catch {} + } + .onChange(of: scrollToTopSignal) { + if viewModel.scrollToTopVisible { + viewModel.isSearchPresented.toggle() + } else { + withAnimation { + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) + } + } } - } - .task(id: viewModel.searchQuery) { - do { - try await Task.sleep(for: .milliseconds(150)) - await viewModel.search() - } catch {} } } @@ -234,4 +256,15 @@ public struct ExploreView: View { .listRowBackground(theme.primaryBackgroundColor) } } + + private var scrollToTopView: some View { + ScrollToView() + .frame(height: .scrollToViewHeight) + .onAppear { + viewModel.scrollToTopVisible = true + } + .onDisappear { + viewModel.scrollToTopVisible = false + } + } } diff --git a/Packages/Explore/Sources/Explore/ExploreViewModel.swift b/Packages/Explore/Sources/Explore/ExploreViewModel.swift index adce23f4..eb160d12 100644 --- a/Packages/Explore/Sources/Explore/ExploreViewModel.swift +++ b/Packages/Explore/Sources/Explore/ExploreViewModel.swift @@ -54,6 +54,8 @@ import SwiftUI var trendingStatuses: [Status] = [] var trendingLinks: [Card] = [] var searchScope: SearchScope = .all + var scrollToTopVisible: Bool = false + var isSearchPresented: Bool = false init() {} diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 150d3504..991ff12d 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -83,9 +83,9 @@ import SwiftUI if let rootHost = host.split(separator: ".", maxSplits: 1).last { // Sometimes the connection is with the root host instead of a subdomain // eg. Mastodon runs on mastdon.domain.com but the connection is with domain.com - return $0.connections.contains(host) || $0.connections.contains(String(rootHost)) + $0.connections.contains(host) || $0.connections.contains(String(rootHost)) } else { - return $0.connections.contains(host) + $0.connections.contains(host) } } } diff --git a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift index 196d3a6a..d9aa19bc 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift @@ -14,21 +14,31 @@ public struct NotificationsListView: View { @Environment(RouterPath.self) private var routerPath @Environment(CurrentAccount.self) private var account @State private var viewModel = NotificationsViewModel() + @Binding var scrollToTopSignal: Int let lockedType: Models.Notification.NotificationType? - public init(lockedType: Models.Notification.NotificationType?) { + public init(lockedType: Models.Notification.NotificationType?, scrollToTopSignal: Binding) { self.lockedType = lockedType + _scrollToTopSignal = scrollToTopSignal } public var body: some View { - List { - topPaddingView - notificationsView + ScrollViewReader { proxy in + List { + scrollToTopView + topPaddingView + notificationsView + } + .id(account.account?.id) + .environment(\.defaultMinListRowHeight, 1) + .listStyle(.plain) + .onChange(of: scrollToTopSignal) { + withAnimation { + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) + } + } } - .id(account.account?.id) - .environment(\.defaultMinListRowHeight, 1) - .listStyle(.plain) .toolbar { ToolbarItem(placement: .principal) { let title = lockedType?.menuTitle() ?? viewModel.selectedType?.menuTitle() ?? "notifications.navigation-title" @@ -196,4 +206,15 @@ public struct NotificationsListView: View { .frame(height: .layoutPadding) .accessibilityHidden(true) } + + private var scrollToTopView: some View { + ScrollToView() + .frame(height: .scrollToViewHeight) + .onAppear { + viewModel.scrollToTopVisible = true + } + .onDisappear { + viewModel.scrollToTopVisible = false + } + } } diff --git a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift index 31c7ae1d..9ad3606a 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift @@ -48,6 +48,8 @@ import SwiftUI } } + var scrollToTopVisible: Bool = false + private var queryTypes: [String]? { if let selectedType { var excludedTypes = Models.Notification.NotificationType.allCases diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift index d8f1e79b..376654b3 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift @@ -15,11 +15,10 @@ actor StatusEditorCompressor { return } - let maxPixelSize: Int - if Bundle.main.bundlePath.hasSuffix(".appex") { - maxPixelSize = 1536 + let maxPixelSize: Int = if Bundle.main.bundlePath.hasSuffix(".appex") { + 1536 } else { - maxPixelSize = 4096 + 4096 } let downsampleOptions = [ diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift index 15b4953c..ec7b2fd1 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift @@ -188,9 +188,9 @@ private func localURLFor(received: ReceivedTransferredFile) -> URL { public extension URL { func mimeType() -> String { if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType { - return mimeType + mimeType } else { - return "application/octet-stream" + "application/octet-stream" } } } diff --git a/Packages/Status/Sources/Status/Row/Subviews/StatusRowContextMenu.swift b/Packages/Status/Sources/Status/Row/Subviews/StatusRowContextMenu.swift index ed86393b..8ead9d92 100644 --- a/Packages/Status/Sources/Status/Row/Subviews/StatusRowContextMenu.swift +++ b/Packages/Status/Sources/Status/Row/Subviews/StatusRowContextMenu.swift @@ -128,8 +128,7 @@ struct StatusRowContextMenu: View { Label("status.action.copy-link", systemImage: "link") } - if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier - { + if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier { Button { Task { await viewModel.translate(userLang: lang) diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index de5a8f0e..2b9898f2 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -9,10 +9,6 @@ import SwiftUIIntrospect @MainActor public struct TimelineView: View { - private enum Constants { - static let scrollToTop = "top" - } - @Environment(\.scenePhase) private var scenePhase @Environment(Theme.self) private var theme @Environment(CurrentAccount.self) private var account @@ -87,7 +83,7 @@ public struct TimelineView: View { } .onChange(of: scrollToTopSignal) { withAnimation { - proxy.scrollTo(Constants.scrollToTop, anchor: .top) + proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) } } } @@ -258,18 +254,13 @@ public struct TimelineView: View { } private var scrollToTopView: some View { - HStack { EmptyView() } - .listRowBackground(theme.primaryBackgroundColor) - .listRowSeparator(.hidden) - .listRowInsets(.init()) + ScrollToView() .frame(height: .layoutPadding) - .id(Constants.scrollToTop) .onAppear { viewModel.scrollToTopVisible = true } .onDisappear { viewModel.scrollToTopVisible = false } - .accessibilityHidden(true) } }