Fix "Load More" Button on Home/Public Timeline (#1283)

* Begin fixing of "Load More" on Home Timeline (IOS-266)

* Don't show "Load More" if last status is first existing (IOS-266)

* Insert missing items upon "Load More" (IOS-266)

* Implement sinceID usage when loading latest posts (IOS-266)

* Change updating of items on Load More(IOS-266)

* Do not try to modify datasource directly (IOS-266)

* Improve load more (IOS-266)

* Fix load more using maxID and limit to 20 items (IOS-266)

* Implement loading missing status in public timeline (IOS-266)

* Implement subsequent "Load More" (IOS-266)

* Make loadMore(item:at:) API more Swifty (IOS-266)

* Address PR comments (IOS-266)
This commit is contained in:
Marcus Kida 2024-05-08 10:02:21 +02:00 committed by GitHub
parent 9b770d1484
commit 677670055e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 95 additions and 27 deletions

View File

@ -693,7 +693,7 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
Task {
await viewModel?.loadMore(item: item)
await viewModel?.loadMore(item: item, at: indexPath)
}
}
}

View File

@ -116,15 +116,20 @@ extension HomeTimelineViewModel.LoadLatestState {
do {
await AuthenticationServiceProvider.shared.fetchAccounts(apiService: viewModel.context.apiService)
let response: Mastodon.Response.Content<[Mastodon.Entity.Status]>
/// To find out wether or not we need to show the "Load More" button
/// we have make sure to eventually overlap with the most recent cached item
let sinceID = latestFeedRecords.count > 1 ? latestFeedRecords[1].id : "1"
switch viewModel.timelineContext {
case .home:
response = try await viewModel.context.apiService.homeTimeline(
sinceID: sinceID,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
case .public:
response = try await viewModel.context.apiService.publicTimeline(
query: .init(local: true),
query: .init(local: true, sinceID: sinceID),
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
}
@ -140,10 +145,25 @@ extension HomeTimelineViewModel.LoadLatestState {
viewModel.didLoadLatest.send()
} else {
viewModel.dataController.records = {
var newRecords: [MastodonFeed] = newStatuses.map {
MastodonFeed.fromStatus(.fromEntity($0), kind: .home)
}
var oldRecords = viewModel.dataController.records
var newRecords = [MastodonFeed]()
/// See HomeTimelineViewModel.swift for the "Load More"-counterpart when fetching new timeline items
for (index, status) in newStatuses.enumerated() {
if index < newStatuses.count - 1 {
newRecords.append(
MastodonFeed.fromStatus(.fromEntity(status), kind: .home, hasMore: false)
)
continue
}
let hasMore = status != oldRecords.first?.status?.entity
newRecords.append(
MastodonFeed.fromStatus(.fromEntity(status), kind: .home, hasMore: hasMore)
)
}
for (i, record) in newRecords.enumerated() {
if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) {
oldRecords[index] = record

View File

@ -148,32 +148,68 @@ extension HomeTimelineViewModel {
extension HomeTimelineViewModel {
// load timeline gap
func loadMore(item: StatusItem) async {
@MainActor
func loadMore(item: StatusItem, at indexPath: IndexPath) async {
guard case let .feedLoader(record) = item else { return }
guard let diffableDataSource = diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
guard let status = record.status else { return }
record.isLoadingMore = true
// reconfigure item
snapshot.reconfigureItems([item])
await updateSnapshotUsingReloadData(snapshot: snapshot)
await AuthenticationServiceProvider.shared.fetchAccounts(apiService: context.apiService)
// fetch data
let maxID = status.id
_ = try? await context.apiService.homeTimeline(
maxID: maxID,
authenticationBox: authContext.mastodonAuthenticationBox
)
let response: Mastodon.Response.Content<[Mastodon.Entity.Status]>?
switch timelineContext {
case .home:
response = try? await context.apiService.homeTimeline(
maxID: status.id,
limit: 20,
authenticationBox: authContext.mastodonAuthenticationBox
)
case .public:
response = try? await context.apiService.publicTimeline(
query: .init(maxID: status.id, limit: 20),
authenticationBox: authContext.mastodonAuthenticationBox
)
}
// insert missing items
guard let items = response?.value else {
record.isLoadingMore = false
return
}
let firstIndex = indexPath.row
let oldRecords = dataController.records
let count = oldRecords.count
let head = oldRecords[..<firstIndex]
let tail = oldRecords[firstIndex..<count]
var feedItems = [MastodonFeed]()
/// See HomeTimelineViewModel+LoadLatestState.swift for the "Load More"-counterpart when fetching new timeline items
for (index, item) in items.enumerated() {
let hasMore: Bool
/// there can only be a gap after the last items
if index < items.count - 1 {
hasMore = false
} else {
/// if fetched items and first item after gap don't match -> we got another gap
hasMore = item != head.first?.status?.entity
}
feedItems.append(
.fromStatus(item.asMastodonStatus, kind: .home, hasMore: hasMore)
)
}
let combinedRecords = Array(head + feedItems + tail)
dataController.records = combinedRecords
record.isLoadingMore = false
// reconfigure item again
snapshot.reconfigureItems([item])
await updateSnapshotUsingReloadData(snapshot: snapshot)
record.hasMore = false
}
}

View File

@ -39,6 +39,10 @@ extension StatusTableViewCell {
case .feed(let feed):
statusView.configure(feed: feed)
self.separatorLine.isHidden = feed.hasMore
feed.$hasMore.sink(receiveValue: { [weak self] hasMore in
self?.separatorLine.isHidden = hasMore
})
.store(in: &disposeBag)
case .status(let status):
statusView.configure(status: status)

View File

@ -18,7 +18,11 @@ public final class MastodonFeed {
}
public let id: String
@Published
public var hasMore: Bool = false
@Published
public var isLoadingMore: Bool = false
public let status: MastodonStatus?
@ -39,9 +43,9 @@ public final class MastodonFeed {
}
public extension MastodonFeed {
static func fromStatus(_ status: MastodonStatus, kind: Feed.Kind) -> MastodonFeed {
static func fromStatus(_ status: MastodonStatus, kind: Feed.Kind, hasMore: Bool? = nil) -> MastodonFeed {
MastodonFeed(
hasMore: false,
hasMore: hasMore ?? false,
isLoadingMore: false,
status: status,
notification: nil,
@ -79,7 +83,8 @@ extension MastodonFeed: Hashable {
lhs.status?.poll == rhs.status?.poll &&
lhs.status?.reblog?.poll == rhs.status?.reblog?.poll &&
lhs.status?.poll?.entity == rhs.status?.poll?.entity &&
lhs.status?.reblog?.poll?.entity == rhs.status?.reblog?.poll?.entity
lhs.status?.reblog?.poll?.entity == rhs.status?.reblog?.poll?.entity &&
lhs.isLoadingMore == rhs.isLoadingMore
}
public func hash(into hasher: inout Hasher) {
@ -94,6 +99,7 @@ extension MastodonFeed: Hashable {
hasher.combine(status?.reblog?.poll)
hasher.combine(status?.poll?.entity)
hasher.combine(status?.reblog?.poll?.entity)
hasher.combine(isLoadingMore)
}
}

View File

@ -37,8 +37,10 @@ extension TimelineMiddleLoaderTableViewCell {
feed: MastodonFeed,
delegate: TimelineMiddleLoaderTableViewCellDelegate?
) {
self.viewModel.isFetching = feed.isLoadingMore
feed.$isLoadingMore
.assign(to: \.isFetching, on: self.viewModel)
.store(in: &disposeBag)
self.delegate = delegate
}