Skip to content

Commit fba8556

Browse files
committed
Improve timeline auto-fetch for filtered empty results
Adds logic to TimelineViewModel to automatically fetch additional pages when all fetched statuses are filtered out, up to a limit. Updates constants for page sizes, and introduces tests and test utilities to verify the new behavior. Also adds a custom decoder to Quote to handle invalid quotedStatus values gracefully.
1 parent 12490a0 commit fba8556

File tree

7 files changed

+223
-14
lines changed

7 files changed

+223
-14
lines changed

CLAUDE.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,17 @@ mcp__XcodeBuildMCP__build_sim_name_proj projectPath: "/Users/thomas/Documents/De
1717

1818
### Running Tests
1919
- **All tests**: Run through Xcode's Test navigator
20-
- **Specific package tests**:
20+
- **Specific package tests (XcodeBuildMCP on simulator)**:
2121
```bash
22-
xcodebuild -scheme AccountTests test
23-
xcodebuild -scheme ModelsTests test
24-
xcodebuild -scheme NetworkTests test
25-
xcodebuild -scheme TimelineTests test
26-
xcodebuild -scheme EnvTests test
27-
```
28-
- **Swift Package Manager** (for individual packages):
29-
```bash
30-
cd Packages/[PackageName]
31-
swift test
22+
# Set defaults once per session
23+
mcp__XcodeBuildMCP__session-set-defaults projectPath: "/Users/thomas/Documents/Dev/Open Source/IceCubesApp/IceCubesApp.xcodeproj" simulatorName: "iPhone Air"
24+
25+
# Then run any package test scheme
26+
mcp__XcodeBuildMCP__test_sim scheme: "AccountTests"
27+
mcp__XcodeBuildMCP__test_sim scheme: "ModelsTests"
28+
mcp__XcodeBuildMCP__test_sim scheme: "NetworkTests"
29+
mcp__XcodeBuildMCP__test_sim scheme: "TimelineTests"
30+
mcp__XcodeBuildMCP__test_sim scheme: "EnvTests"
3231
```
3332

3433
### Code Formatting

Packages/Models/Sources/Models/Quote.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@ public struct Quote: Codable, Sendable {
88
public let state: State?
99
public let quotedStatus: Status?
1010
public let quotedStatusId: String?
11+
12+
public init(from decoder: Decoder) throws {
13+
let container = try decoder.container(keyedBy: CodingKeys.self)
14+
state = try? container.decode(State.self, forKey: .state)
15+
quotedStatusId = try? container.decode(String.self, forKey: .quotedStatusId)
16+
quotedStatus = try? container.decode(Status.self, forKey: .quotedStatus)
17+
}
1118
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import Models
5+
6+
@Test
7+
func testQuoteDecodingIgnoresInvalidQuotedStatus() throws {
8+
let decoder = JSONDecoder()
9+
decoder.keyDecodingStrategy = .convertFromSnakeCase
10+
11+
let json = """
12+
{
13+
"state": "accepted",
14+
"quoted_status_id": "123",
15+
"quoted_status": "invalid"
16+
}
17+
"""
18+
19+
let quote = try decoder.decode(Quote.self, from: Data(json.utf8))
20+
#expect(quote.state == .accepted)
21+
#expect(quote.quotedStatusId == "123")
22+
#expect(quote.quotedStatus == nil)
23+
}

Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ import SwiftUI
6969
private enum Constants {
7070
static let fullTimelineFetchLimit = 800
7171
static let fullTimelineFetchMaxPages = fullTimelineFetchLimit / 40
72+
static let initialPageLimit = 50
73+
static let nextPageLimit = 40
74+
static let emptyFilterAutoPageLimit = 3
7275
}
7376

7477
private var isFullTimelineFetchEnabled: Bool {
@@ -276,10 +279,20 @@ extension TimelineViewModel: GapLoadingFetcher {
276279
timeline: timeline)
277280

278281
await updateDatasourceAndState(statuses: statuses, client: client, replaceExisting: true)
282+
let lastCount = await autoFetchNextPagesIfFilteredEmpty(
283+
lastFetchedCount: statuses.count,
284+
pageLimit: Constants.initialPageLimit)
285+
if lastCount != statuses.count {
286+
await cache()
287+
await updateStatusesStateWithAnimation()
288+
}
279289

280290
// If we got 40 or more statuses, there might be older ones - create a gap
281-
if statuses.count >= 40, let oldestStatus = statuses.last, !datasourceIsEmpty {
282-
await createGapForOlderStatuses(maxId: oldestStatus.id, at: statuses.count)
291+
if lastCount >= Constants.nextPageLimit, !datasourceIsEmpty {
292+
let allStatuses = await datasource.get()
293+
if let oldestStatus = allStatuses.last {
294+
await createGapForOlderStatuses(maxId: oldestStatus.id, at: allStatuses.count)
295+
}
283296
}
284297
}
285298
}
@@ -421,9 +434,13 @@ extension TimelineViewModel: GapLoadingFetcher {
421434
await datasource.append(contentOf: newStatuses)
422435
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
423436

437+
let lastCount = await autoFetchNextPagesIfFilteredEmpty(
438+
lastFetchedCount: newStatuses.count,
439+
pageLimit: Constants.nextPageLimit)
440+
await cache()
424441
statusesState = await .displayWithGaps(
425442
items: datasource.getFilteredItems(),
426-
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
443+
nextPageState: lastCount < Constants.nextPageLimit ? .none : .hasNextPage)
427444
}
428445

429446
func statusDidAppear(status: Status) {
@@ -527,6 +544,43 @@ extension TimelineViewModel: GapLoadingFetcher {
527544
}
528545
}
529546

547+
private func autoFetchNextPagesIfFilteredEmpty(
548+
lastFetchedCount: Int,
549+
pageLimit: Int
550+
) async -> Int {
551+
guard lastFetchedCount >= pageLimit else { return lastFetchedCount }
552+
guard await datasource.getFilteredItems().isEmpty else { return lastFetchedCount }
553+
guard let client else { return lastFetchedCount }
554+
555+
var pagesLoaded = 0
556+
var lastCount = lastFetchedCount
557+
558+
while pagesLoaded < Constants.emptyFilterAutoPageLimit,
559+
lastCount >= Constants.nextPageLimit,
560+
await datasource.getFilteredItems().isEmpty
561+
{
562+
let statuses = await datasource.get()
563+
guard let lastId = statuses.last?.id else { break }
564+
let newStatuses: [Status]
565+
do {
566+
newStatuses = try await statusFetcher.fetchNextPage(
567+
client: client,
568+
timeline: timeline,
569+
lastId: lastId,
570+
offset: statuses.count)
571+
} catch {
572+
break
573+
}
574+
guard !newStatuses.isEmpty else { break }
575+
await datasource.append(contentOf: newStatuses)
576+
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
577+
lastCount = newStatuses.count
578+
pagesLoaded += 1
579+
}
580+
581+
return lastCount
582+
}
583+
530584
private func createGapForOlderStatuses(sinceId: String? = nil, maxId: String, at index: Int) async
531585
{
532586
guard !isFullTimelineFetchEnabled else { return }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Models
2+
import NetworkClient
3+
4+
@testable import Timeline
5+
6+
actor MockTimelineStatusFetcher: TimelineStatusFetching {
7+
private let firstPage: [Status]
8+
private let nextPages: [[Status]]
9+
private var nextPageCalls: Int = 0
10+
11+
init(firstPage: [Status], nextPages: [[Status]]) {
12+
self.firstPage = firstPage
13+
self.nextPages = nextPages
14+
}
15+
16+
func fetchFirstPage(client: MastodonClient?, timeline: TimelineFilter) async throws -> [Status] {
17+
firstPage
18+
}
19+
20+
func fetchNewPages(
21+
client: MastodonClient?,
22+
timeline: TimelineFilter,
23+
minId: String,
24+
maxPages: Int
25+
) async throws -> [Status] {
26+
[]
27+
}
28+
29+
func fetchNextPage(
30+
client: MastodonClient?,
31+
timeline: TimelineFilter,
32+
lastId: String,
33+
offset: Int
34+
) async throws -> [Status] {
35+
defer { nextPageCalls += 1 }
36+
guard nextPageCalls < nextPages.count else { return [] }
37+
return nextPages[nextPageCalls]
38+
}
39+
40+
func nextPageCallCount() -> Int {
41+
nextPageCalls
42+
}
43+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
import Models
3+
4+
func makeStatus(id: String, hidden: Bool) -> Status {
5+
let base = Status.placeholder()
6+
let filtered: [Filtered]? = hidden ? [makeHiddenFilter()].compactMap { $0 } : nil
7+
8+
return Status(
9+
id: id,
10+
content: base.content,
11+
account: base.account,
12+
createdAt: base.createdAt,
13+
editedAt: base.editedAt,
14+
reblog: base.reblog,
15+
mediaAttachments: base.mediaAttachments,
16+
mentions: base.mentions,
17+
repliesCount: base.repliesCount,
18+
reblogsCount: base.reblogsCount,
19+
favouritesCount: base.favouritesCount,
20+
card: base.card,
21+
favourited: base.favourited,
22+
reblogged: base.reblogged,
23+
pinned: base.pinned,
24+
bookmarked: base.bookmarked,
25+
emojis: base.emojis,
26+
url: base.url,
27+
application: base.application,
28+
inReplyToId: base.inReplyToId,
29+
inReplyToAccountId: base.inReplyToAccountId,
30+
visibility: base.visibility,
31+
poll: base.poll,
32+
spoilerText: base.spoilerText,
33+
filtered: filtered,
34+
sensitive: base.sensitive,
35+
language: base.language,
36+
tags: base.tags,
37+
quote: nil,
38+
quotesCount: nil,
39+
quoteApproval: nil)
40+
}
41+
42+
private func makeHiddenFilter() -> Filtered? {
43+
let json = """
44+
{
45+
"filter": {
46+
"id": "filter",
47+
"title": "Hidden",
48+
"context": ["home"],
49+
"filterAction": "hide"
50+
},
51+
"keywordMatches": null
52+
}
53+
"""
54+
55+
return try? JSONDecoder().decode(Filtered.self, from: Data(json.utf8))
56+
}

Packages/Timeline/Tests/TimelineTests/TimelineViewModelTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,31 @@ struct Tests {
106106
#expect(count == 1)
107107
#expect(statuses.first?.content.asRawText == "test")
108108
}
109+
110+
@Test
111+
func autoFetchesWhenFilteredStatusesAreEmpty() async throws {
112+
let contentFilter = TimelineContentFilter.shared
113+
contentFilter.showBoosts = true
114+
contentFilter.showReplies = true
115+
contentFilter.showThreads = true
116+
contentFilter.showQuotePosts = true
117+
118+
let hiddenFirstPage = (0..<50).map { makeStatus(id: "hidden-first-\($0)", hidden: true) }
119+
let hiddenSecondPage = (0..<40).map { makeStatus(id: "hidden-next-\($0)", hidden: true) }
120+
let visibleThirdPage = [makeStatus(id: "visible-0", hidden: false)]
121+
122+
let fetcher = MockTimelineStatusFetcher(
123+
firstPage: hiddenFirstPage,
124+
nextPages: [hiddenSecondPage, visibleThirdPage])
125+
126+
let subject = TimelineViewModel(statusFetcher: fetcher)
127+
subject.client = MastodonClient(server: "localhost")
128+
await subject.reset()
129+
130+
await subject.fetchNewestStatuses(pullToRefresh: false)
131+
132+
let filteredItems = await subject.datasource.getFilteredItems()
133+
#expect(!filteredItems.isEmpty)
134+
#expect(await fetcher.nextPageCallCount() >= 2)
135+
}
109136
}

0 commit comments

Comments
 (0)