Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ public class MastodonFeedLoader<PublishedType: Identifiable, CachedType: Cacheab
allRecords: [], nextBottomLoad: .initializing)
@Published private(set) var currentError: Error? = nil

/// Last time `fetchResults` completed and records were published.
private(set) var lastSuccessfulFetchAt: Date? = nil

/// Bumped to abandon in-flight fetches that iOS froze in the background or that pull-to-refresh superseded.
private var loadGeneration: Int = 0

init(_ cacheManager: (any MastodonFeedCacheManager<CachedType>)) {
self.cacheManager = cacheManager

Expand All @@ -102,15 +108,36 @@ public class MastodonFeedLoader<PublishedType: Identifiable, CachedType: Cacheab
}
}

private var fetchStartedAt: Date? = nil

var isActivelyFetching: Bool { isFetching }

/// URLSession tasks suspended in the background often never complete, so `isFetching` stays true forever.
var fetchAppearsStuck: Bool {
guard isFetching, let fetchStartedAt else { return false }
return Date().timeIntervalSince(fetchStartedAt) > 15
}

private var isFetching: Bool = false {
didSet {
if isFetching {
if fetchStartedAt == nil {
fetchStartedAt = Date()
}
} else {
fetchStartedAt = nil
}
if !isFetching, let waitingRequest = nextRequestThatCanBeLoadedNow() {
Task {
do {
try await load(waitingRequest)
currentError = nil
} catch {
currentError = error
if Self.isCancellation(error) {
currentError = nil
} else {
currentError = error
}
}
}
}
Expand All @@ -125,6 +152,19 @@ public class MastodonFeedLoader<PublishedType: Identifiable, CachedType: Cacheab
return nextRequest
}

/// Drop queued work and make any in-flight `load` ignore its result. Call when entering the background so a frozen socket cannot pin `isFetching`.
func invalidateInFlightLoad() {
loadRequestQueue.removeAll()
loadGeneration += 1
isFetching = false
}

private static func isCancellation(_ error: Error) -> Bool {
if error is CancellationError { return true }
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
}

func setRecords(_ records: MastodonFeedLoaderResult<PublishedType>) {
self.records = records
}
Expand Down Expand Up @@ -173,20 +213,22 @@ extension MastodonFeedLoader {
try await load(nextDoableRequest)
currentError = nil
} catch {
currentError = error
if Self.isCancellation(error) {
currentError = nil
} else {
currentError = error
}
}
}
}
}

/// Use only with pull to refresh, in order to properly update the progress spinner.
/// Always succeeds: a stuck in-flight fetch is abandoned so the user is never stuck needing a force-quit.
public var permissionToLoadImmediately: Bool {
if isFetching {
return false
} else {
isFetching = true
return true
}
invalidateInFlightLoad()
isFetching = true
return true
}
/// Use only with pull to refresh, in order to properly update the progress spinner.
public func loadImmediately(_ request: MastodonFeedLoaderRequest) async {
Expand All @@ -195,7 +237,11 @@ extension MastodonFeedLoader {
try await load(request)
currentError = nil
} catch {
currentError = error
if Self.isCancellation(error) {
currentError = nil
} else {
currentError = error
}
}
}

Expand All @@ -204,9 +250,16 @@ extension MastodonFeedLoader {
if request == .reloadForFilterChange, let resetTimeline = resetTimeline() {
updateAfterInserting(newlyFetchedResults: resetTimeline, at: .replace)
}
defer { isFetching = false }
let generation = loadGeneration
defer {
if generation == loadGeneration {
isFetching = false
}
}
let unfiltered = try await fetchResults(for: request)
guard generation == loadGeneration else { return }
updateAfterInserting(newlyFetchedResults: unfiltered, at: request.resultsInsertionPoint)
lastSuccessfulFetchAt = Date()
}

func updateAfterInserting(newlyFetchedResults: CachedType, at insertionPoint: MastodonFeedLoaderRequest.InsertLocation) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.

import SwiftUI
import UIKit
import MastodonAsset
import MastodonCore
import MastodonLocalization
Expand Down Expand Up @@ -352,6 +353,7 @@ enum MastodonTimelineSheet: Identifiable {
case notificationFilterPolicyUpdated
case userRequestedRefresh
case notificationCountUpdated
case becameVisible
case asyncRefreshResultsRequested
case activityFilterUpdated
case mediaFilterUpdated
Expand All @@ -365,6 +367,26 @@ enum MastodonTimelineSheet: Identifiable {
FilteredNotificationsRowView.ViewModel(policy: nil)
var needsReloadOnNextAppear = false
var notificationRequestsAcceptanceDidChange = false
/// After a top-of-feed reload, jump the scroll position to the newest items instead of keeping the old anchor (which hides new rows above it).
var jumpToTopAfterNextReload = false
private var isReloadFromVisibilityScheduled = false
private var sceneLifecycleSubscriptions = Set<AnyCancellable>()

private var isScrolledNearTop: Bool {
guard let anchorIndex = currentDisplaySlice.firstIndex(of: scrollAnchorItem) else {
return true
}
return anchorIndex <= 1
}

private func topAnchorItem(in items: [TimelineItem]) -> TimelineItem {
switch timeline {
case .notifications(.everything), .notifications(.mentions):
return .filteredNotificationsInfo(filteredNotificationsViewModel.policy, filteredNotificationsViewModel)
default:
return items.first ?? .noItem
}
}

// MARK - Sheets
@ViewBuilder func activeSheetContents(_ activeSheet: MastodonTimelineSheet, navigator: MastodonNavigationRouter) -> some View {
Expand Down Expand Up @@ -535,11 +557,65 @@ enum MastodonTimelineSheet: Identifiable {
self.authenticatedUser = AuthenticationServiceProvider.shared.currentActiveUser.value
}

observeSceneLifecycle()

Task {
try await doInitialLoad(navigator: navigator)
}
}

private func observeSceneLifecycle() {
NotificationCenter.default.publisher(for: UIScene.didActivateNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reloadWhenBecomingVisible()
}
.store(in: &sceneLifecycleSubscriptions)

NotificationCenter.default.publisher(for: UIScene.didEnterBackgroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.feedLoader?.invalidateInFlightLoad()
}
.store(in: &sceneLifecycleSubscriptions)
}

/// REST feeds are not streamed. Refresh when the scene/tab becomes visible, otherwise the user keeps seeing a snapshot from the last successful fetch until force-quit.
func reloadWhenBecomingVisible() {
guard feedLoader != nil else { return }
switch timeline {
case .homeTimeline, .notifications:
break
default:
// `.reload` replaces the whole page. Auto-refreshing bookmarks/hashtags/lists on resume would drop already-paginated older items.
return
}
switch loadingState {
case .initializing:
return
default:
break
}
let forceBecauseOfNewNotifications = needsReloadOnNextAppear
if !forceBecauseOfNewNotifications,
let last = feedLoader?.lastSuccessfulFetchAt,
Date().timeIntervalSince(last) < 3 {
return
}
if !forceBecauseOfNewNotifications,
(isReloadFromVisibilityScheduled || (feedLoader?.isActivelyFetching == true && feedLoader?.fetchAppearsStuck != true)) {
return
}
needsReloadOnNextAppear = false
isReloadFromVisibilityScheduled = true
jumpToTopAfterNextReload = isScrolledNearTop
loadingState = .requestedReloadFromTop
Task {
defer { isReloadFromVisibilityScheduled = false }
await forceReload(.becameVisible)
}
}

func setUpFeedLoaderResultsSubscription() {
let subscriptionTimeline = feedLoader?.timeline
feedLoaderResultsSubscription = feedLoader?.$records
Expand Down Expand Up @@ -622,7 +698,35 @@ enum MastodonTimelineSheet: Identifiable {
newItemsCount = 0 // ... so there will be no new items above the visible point
safeToSetNewItemsImmediately = true

case .requestedReloadFromTop, .requestedPrependedHeightCalculations, .untracked:
case .requestedReloadFromTop:
let previousFirstItem = self.currentDisplaySlice.first(where: { $0.isRealItem })
let currentFeedIsEmpty = previousFirstItem == nil
let jumpToTop = jumpToTopAfterNextReload || isScrolledNearTop || currentFeedIsEmpty
jumpToTopAfterNextReload = false
// Always apply the new page. Holding it in `waitingReplacementItems` until the next pull-to-refresh made Notifications look frozen.
safeToSetNewItemsImmediately = true
if jumpToTop {
newScrollAnchor = topAnchorItem(in: items)
newItemsCount = 0
} else if let previousFirstItem, let newIndex = items.firstIndex(of: previousFirstItem) {
newScrollAnchor = nil
newItemsCount = newIndex
} else if let newIndex = items.firstIndex(of: self.scrollAnchorItem) {
newScrollAnchor = nil
newItemsCount = newIndex
} else {
newScrollAnchor = topAnchorItem(in: items)
newItemsCount = 0
}

case .requestedPrependedHeightCalculations, .untracked:
if jumpToTopAfterNextReload {
jumpToTopAfterNextReload = false
safeToSetNewItemsImmediately = true
newScrollAnchor = topAnchorItem(in: items)
newItemsCount = 0
break
}
// The new set of results may not include the current scroll anchor. In that case, just show the new items snackbar and wait to do the actual reload (by tapping on the snackbar or doing a pull to refresh).
let previousFirstItem = self.currentDisplaySlice.first(where: { $0.isRealItem })
let currentFeedIsEmpty = previousFirstItem == nil
Expand Down Expand Up @@ -755,10 +859,13 @@ enum MastodonTimelineSheet: Identifiable {

func refreshFromTop() async {
assert(loadingState == .requestedReloadFromTop, "Caller must synchronously set loading state before requesting async reload.")
jumpToTopAfterNextReload = true
if let waiting = waitingReplacementItems, !waiting.isEmpty {
scrollToTop()
}
await _refreshFromTop()
if let waiting = waitingReplacementItems, !waiting.isEmpty {
scrollToTop()
} else {
await _refreshFromTop()
}
resetToUntrackedAfterDelay(from: loadingState)
}
Expand Down Expand Up @@ -786,11 +893,19 @@ enum MastodonTimelineSheet: Identifiable {
}
needsReloadOnNextAppear = false
switch reason {
case .notificationCountUpdated:
MastodonTabViewRouter.current.fetchFilteredNotificationsPolicy(andReloadFeed: true)
case .notificationCountUpdated, .becameVisible:
if timeline.canDisplayFilteredNotifications {
MastodonTabViewRouter.current.fetchFilteredNotificationsPolicy(andReloadFeed: false)
}
if feedLoader.permissionToLoadImmediately {
await feedLoader.loadImmediately(.reload)
commitToCache()
}
case .notificationFilterPolicyUpdated:
loadingState = .requestedReloadFromTop
feedLoader.requestLoad(.reload)
if feedLoader.permissionToLoadImmediately {
await feedLoader.loadImmediately(.reload)
}
case .activityFilterUpdated, .mediaFilterUpdated:
loadingState = .initializing
interactiveReloadTriggerModel.reset(triggered: true)
Expand Down Expand Up @@ -930,8 +1045,9 @@ extension TimelineListViewModel {
_ policy: Mastodon.Entity.NotificationPolicy?,
andReloadFeed reload: Bool
) {
guard filteredNotificationsViewModel.policy != policy else { return }
filteredNotificationsViewModel.policy = policy
if filteredNotificationsViewModel.policy != policy {
filteredNotificationsViewModel.policy = policy
}
guard reload else { return }

switch loadingState {
Expand Down Expand Up @@ -1138,10 +1254,10 @@ extension TimelineListViewModel {

var canReload: Bool {
switch self {
case .untracked:
true
default:
case .initializing, .requestedPrependedHeightCalculations:
false
default:
true
}
}
}
Expand Down Expand Up @@ -1401,12 +1517,7 @@ struct TimelineListView: View {
// clear the notification dot on the tab icon
NotificationService.shared.clearNotificationCountForActiveUser()
}
if viewModel.needsReloadOnNextAppear {
viewModel.needsReloadOnNextAppear = false
Task {
await viewModel.forceReload(.notificationCountUpdated)
}
}
viewModel.reloadWhenBecomingVisible()
}
.onDisappear() {
viewModel.loadingState = .untracked
Expand Down Expand Up @@ -2955,7 +3066,7 @@ struct Snackbar: View {
extension MastodonTimelineType {
var canDisplayNewItemsSnackbar: Bool {
switch self {
case .homeTimeline:
case .homeTimeline, .notifications:
true
default:
false
Expand Down
2 changes: 2 additions & 0 deletions MastodonSDK/Sources/MastodonCore/Service/API/APIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public final class APIService {
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown"
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = ["User-Agent" : "mastodon-ios/" + appVersion]
// Default resource timeout is 7 days, which leaves zombie tasks after iOS freezes the socket in the background.
configuration.timeoutIntervalForResource = 60
self.session = URLSession(configuration: configuration)

// setup cache. 10MB RAM + 50MB Disk
Expand Down