diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index b88123f8..a9066c9d 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -112,7 +112,7 @@ public struct AccountDetailView: View { group.addTask { await viewModel.fetchAccount() } group.addTask { if await viewModel.statuses.isEmpty { - await viewModel.fetchNewestStatuses() + await viewModel.fetchNewestStatuses(pullToRefresh: false) } } if !viewModel.isCurrentUser { @@ -126,7 +126,7 @@ public struct AccountDetailView: View { SoundEffectManager.shared.playSound(.pull) HapticManager.shared.fireHaptic(.dataRefresh(intensity: 0.3)) await viewModel.fetchAccount() - await viewModel.fetchNewestStatuses() + await viewModel.fetchNewestStatuses(pullToRefresh: true) HapticManager.shared.fireHaptic(.dataRefresh(intensity: 0.7)) SoundEffectManager.shared.playSound(.refresh) } diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index a8c73f35..8a3e8972 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -88,7 +88,7 @@ import SwiftUI case .statuses, .postsAndReplies, .media: tabTask?.cancel() tabTask = Task { - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: false) } default: reloadTabState() @@ -170,7 +170,7 @@ import SwiftUI self.familiarFollowers = familiarFollowers?.first?.accounts ?? [] } - func fetchNewestStatuses() async { + func fetchNewestStatuses(pullToRefresh: Bool) async { guard let client else { return } do { tabState = .statuses(statusesState: .loading) diff --git a/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListView.swift b/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListView.swift index 1d9a803b..8f49366f 100644 --- a/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListView.swift +++ b/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListView.swift @@ -30,12 +30,12 @@ public struct AccountStatusesListView: View { .navigationTitle(viewModel.mode.title) .navigationBarTitleDisplayMode(.inline) .refreshable { - await viewModel.fetchNewestStatuses() + await viewModel.fetchNewestStatuses(pullToRefresh: true) } .task { guard !isLoaded else { return } viewModel.client = client - await viewModel.fetchNewestStatuses() + await viewModel.fetchNewestStatuses(pullToRefresh: false) isLoaded = true } } diff --git a/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListViewModel.swift b/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListViewModel.swift index d3eb37d0..19f4f869 100644 --- a/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListViewModel.swift +++ b/Packages/Account/Sources/Account/StatusesLists/AccountStatusesListViewModel.swift @@ -40,7 +40,7 @@ public class AccountStatusesListViewModel: StatusesFetcher { self.mode = mode } - public func fetchNewestStatuses() async { + public func fetchNewestStatuses(pullToRefresh: Bool) async { guard let client else { return } statusesState = .loading do { diff --git a/Packages/Status/Sources/Status/List/StatusesFetcher.swift b/Packages/Status/Sources/Status/List/StatusesFetcher.swift index ed8d0d6b..a90427d8 100644 --- a/Packages/Status/Sources/Status/List/StatusesFetcher.swift +++ b/Packages/Status/Sources/Status/List/StatusesFetcher.swift @@ -16,7 +16,7 @@ public enum StatusesState { @MainActor public protocol StatusesFetcher { var statusesState: StatusesState { get } - func fetchNewestStatuses() async + func fetchNewestStatuses(pullToRefresh: Bool) async func fetchNextPage() async func statusDidAppear(status: Status) func statusDidDisappear(status: Status) diff --git a/Packages/Status/Sources/Status/List/StatusesListView.swift b/Packages/Status/Sources/Status/List/StatusesListView.swift index 0f5cda11..9490a358 100644 --- a/Packages/Status/Sources/Status/List/StatusesListView.swift +++ b/Packages/Status/Sources/Status/List/StatusesListView.swift @@ -40,7 +40,7 @@ public struct StatusesListView: View where Fetcher: StatusesFetcher { buttonTitle: "action.retry") { Task { - await fetcher.fetchNewestStatuses() + await fetcher.fetchNewestStatuses(pullToRefresh: false) } } .listRowBackground(theme.primaryBackgroundColor) diff --git a/Packages/Timeline/Sources/Timeline/TimelineUnreadStatusesObserver.swift b/Packages/Timeline/Sources/Timeline/TimelineUnreadStatusesObserver.swift index 342075c1..6842568c 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineUnreadStatusesObserver.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineUnreadStatusesObserver.swift @@ -11,6 +11,8 @@ import DesignSystem var disableUpdate: Bool = false var scrollToIndex: ((Int) -> Void)? + + var isLoadingNewStatuses: Bool = false var pendingStatuses: [String] = [] { didSet { @@ -36,17 +38,26 @@ struct TimelineUnreadStatusesView: View { @Environment(Theme.self) private var theme var body: some View { - if observer.pendingStatusesCount > 0 { + if observer.pendingStatusesCount > 0 || observer.isLoadingNewStatuses { Button { observer.scrollToIndex?(observer.pendingStatusesCount) } label: { - Text("\(observer.pendingStatusesCount)") - .contentTransition(.numericText(value: Double(observer.pendingStatusesCount))) - // Accessibility: this results in a frame with a size of at least 44x44 at regular font size - .frame(minWidth: 16, minHeight: 16) - .font(.footnote.monospacedDigit()) - .fontWeight(.bold) - .foregroundStyle(theme.labelColor) + HStack { + if observer.isLoadingNewStatuses { + ProgressView() + .tint(theme.labelColor) + .transition(.scale) + } + if observer.pendingStatusesCount > 0 { + Text("\(observer.pendingStatusesCount)") + .contentTransition(.numericText(value: Double(observer.pendingStatusesCount))) + // Accessibility: this results in a frame with a size of at least 44x44 at regular font size + .frame(minWidth: 16, minHeight: 16) + .font(.footnote.monospacedDigit()) + .fontWeight(.bold) + .foregroundStyle(theme.labelColor) + } + } } .accessibilityLabel("accessibility.tabs.timeline.unread-posts.label-\(observer.pendingStatusesCount)") .accessibilityHint("accessibility.tabs.timeline.unread-posts.hint") diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift index 71006aa4..9ebd62d4 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift @@ -30,7 +30,7 @@ import SwiftUI return } - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: false) switch timeline { case let .hashtag(tag, _): await fetchTag(id: tag) @@ -55,7 +55,13 @@ import SwiftUI @ObservationIgnored private var visibileStatuses: [Status] = [] - private var canStreamEvents: Bool = true + private var canStreamEvents: Bool = true { + didSet { + if canStreamEvents { + pendingStatusesObserver.isLoadingNewStatuses = false + } + } + } @ObservationIgnored var canFilterTimeline: Bool = true @@ -186,7 +192,7 @@ extension TimelineViewModel: StatusesFetcher { if !timeline.supportNewestPagination || UserPreferences.shared.fastRefreshEnabled { await reset() } - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: true) } func refreshTimeline() { @@ -195,7 +201,7 @@ extension TimelineViewModel: StatusesFetcher { if UserPreferences.shared.fastRefreshEnabled { await reset() } - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: false) } } @@ -218,10 +224,10 @@ extension TimelineViewModel: StatusesFetcher { statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: false) } - func fetchNewestStatuses() async { + func fetchNewestStatuses(pullToRefresh: Bool) async { guard let client else { return } do { if let marker { @@ -229,6 +235,7 @@ extension TimelineViewModel: StatusesFetcher { } else if await datasource.isEmpty { try await fetchFirstPage(client: client) } else if let latest = await datasource.get().first, timeline.supportNewestPagination { + pendingStatusesObserver.isLoadingNewStatuses = !pullToRefresh try await fetchNewPagesFrom(latestStatus: latest.id, client: client) } } catch { @@ -269,7 +276,7 @@ extension TimelineViewModel: StatusesFetcher { } } // And then we fetch statuses again toget newest statuses from there. - await fetchNewestStatuses() + await fetchNewestStatuses(pullToRefresh: false) } else { var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil, @@ -370,6 +377,7 @@ extension TimelineViewModel: StatusesFetcher { !Task.isCancelled, let latest = await datasource.get().first { + pendingStatusesObserver.isLoadingNewStatuses = true try await fetchNewPagesFrom(latestStatus: latest.id, client: client) } }