diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 9b57f14..4af20b3 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -74,6 +74,7 @@ extension ContentDatabase { try db.create(table: "loadMoreRecord") { t in t.column("timelineId").notNull().references("timelineRecord", onDelete: .cascade) t.column("afterStatusId", .text).notNull() + t.column("beforeStatusId", .text).notNull() t.primaryKey(["timelineId", "afterStatusId"], onConflict: .replace) } diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 92312d9..0478f6c 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -49,7 +49,10 @@ public extension ContentDatabase { .eraseToAnyPublisher() } - func insert(statuses: [Status], timeline: Timeline) -> AnyPublisher { + func insert( + statuses: [Status], + timeline: Timeline, + loadMoreAndDirection: (LoadMore, LoadMore.Direction)? = nil) -> AnyPublisher { databaseWriter.writePublisher { let timelineRecord = TimelineRecord(timeline: timeline) @@ -66,7 +69,38 @@ public extension ContentDatabase { if let maxIDPresent = maxIDPresent, let minIDInserted = statuses.map(\.id).min(), minIDInserted > maxIDPresent { - try LoadMoreRecord(timelineId: timeline.id, afterStatusId: minIDInserted).save($0) + try LoadMoreRecord( + timelineId: timeline.id, + afterStatusId: minIDInserted, + beforeStatusId: maxIDPresent) + .save($0) + } + + guard let (loadMore, direction) = loadMoreAndDirection else { return } + + try LoadMoreRecord( + timelineId: loadMore.timeline.id, + afterStatusId: loadMore.afterStatusId, + beforeStatusId: loadMore.beforeStatusId) + .delete($0) + + switch direction { + case .up: + if let maxIDInserted = statuses.map(\.id).max(), maxIDInserted < loadMore.afterStatusId { + try LoadMoreRecord( + timelineId: loadMore.timeline.id, + afterStatusId: loadMore.afterStatusId, + beforeStatusId: maxIDInserted) + .save($0) + } + case .down: + if let minIDInserted = statuses.map(\.id).min(), minIDInserted > loadMore.beforeStatusId { + try LoadMoreRecord( + timelineId: loadMore.timeline.id, + afterStatusId: minIDInserted, + beforeStatusId: loadMore.beforeStatusId) + .save($0) + } } } .ignoreOutput() diff --git a/DB/Sources/DB/Content/LoadMoreRecord.swift b/DB/Sources/DB/Content/LoadMoreRecord.swift index 38a4275..9005b2a 100644 --- a/DB/Sources/DB/Content/LoadMoreRecord.swift +++ b/DB/Sources/DB/Content/LoadMoreRecord.swift @@ -7,12 +7,14 @@ import Mastodon struct LoadMoreRecord: Codable, Hashable { let timelineId: String let afterStatusId: String + let beforeStatusId: String } extension LoadMoreRecord { enum Columns { static let timelineId = Column(LoadMoreRecord.CodingKeys.timelineId) static let afterStatusId = Column(LoadMoreRecord.CodingKeys.afterStatusId) + static let beforeStatusId = Column(LoadMoreRecord.CodingKeys.beforeStatusId) } } diff --git a/DB/Sources/DB/Content/TimelineItemsInfo.swift b/DB/Sources/DB/Content/TimelineItemsInfo.swift index 46c1a75..559e9e8 100644 --- a/DB/Sources/DB/Content/TimelineItemsInfo.swift +++ b/DB/Sources/DB/Content/TimelineItemsInfo.swift @@ -42,7 +42,10 @@ extension TimelineItemsInfo { }) else { continue } timelineItems.insert( - .loadMore(LoadMore(timeline: timeline, afterStatusId: loadMoreRecord.afterStatusId)), + .loadMore(LoadMore( + timeline: timeline, + afterStatusId: loadMoreRecord.afterStatusId, + beforeStatusId: loadMoreRecord.beforeStatusId)), at: index) } diff --git a/DB/Sources/DB/Content/TimelineRecord.swift b/DB/Sources/DB/Content/TimelineRecord.swift index f64dce1..ca6a229 100644 --- a/DB/Sources/DB/Content/TimelineRecord.swift +++ b/DB/Sources/DB/Content/TimelineRecord.swift @@ -38,7 +38,7 @@ extension TimelineRecord { StatusRecord.self, through: statusJoins, using: TimelineStatusJoin.status) - .order(StatusRecord.Columns.createdAt.desc) + .order(StatusRecord.Columns.id.desc) static let account = belongsTo(AccountRecord.self, using: ForeignKey([Columns.accountId])) static let loadMores = hasMany(LoadMoreRecord.self) diff --git a/DB/Sources/DB/Entities/LoadMore.swift b/DB/Sources/DB/Entities/LoadMore.swift index 2333f76..e3bb319 100644 --- a/DB/Sources/DB/Entities/LoadMore.swift +++ b/DB/Sources/DB/Entities/LoadMore.swift @@ -1,12 +1,12 @@ // Copyright © 2020 Metabolist. All rights reserved. import Foundation -import GRDB import Mastodon public struct LoadMore: Hashable { public let timeline: Timeline public let afterStatusId: String + public let beforeStatusId: String } public extension LoadMore { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 45b2d56..76673f2 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -54,6 +54,8 @@ D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; }; + D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */; }; D0EA59402522AC8700804347 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA593F2522AC8700804347 /* CardView.swift */; }; D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; }; D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; }; @@ -154,6 +156,8 @@ D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = ""; }; + D0E569DA2529319100FA1D72 /* LoadMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreView.swift; sourceTree = ""; }; + D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreContentConfiguration.swift; sourceTree = ""; }; D0EA593F2522AC8700804347 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; D0EA59472522B8B600804347 /* ViewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewConstants.swift; sourceTree = ""; }; D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; @@ -291,6 +295,8 @@ D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0B8510B25259E56004E0744 /* LoadMoreCell.swift */, + D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, + D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, @@ -534,6 +540,7 @@ D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, + D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, @@ -544,6 +551,7 @@ D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, + D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift b/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift index f92101e..b316dc3 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift @@ -21,8 +21,13 @@ public extension LoadMoreService { mastodonAPIClient.pagedRequest( loadMore.timeline.endpoint, maxID: direction == .down ? loadMore.afterStatusId : nil, - minID: direction == .up ? loadMore.afterStatusId : nil) - .flatMap { contentDatabase.insert(statuses: $0.result, timeline: loadMore.timeline) } + minID: direction == .up ? loadMore.beforeStatusId : nil) + .flatMap { + contentDatabase.insert( + statuses: $0.result, + timeline: loadMore.timeline, + loadMoreAndDirection: (loadMore, direction)) + } .eraseToAnyPublisher() } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index 3ed88f4..b17c77e 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -74,6 +74,10 @@ public extension NavigationService { func accountService(account: Account) -> AccountService { AccountService(account: account, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func loadMoreService(loadMore: LoadMore) -> LoadMoreService { + LoadMoreService(loadMore: loadMore, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } } private extension NavigationService { diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index e4d7757..be32381 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -28,11 +28,7 @@ class TableViewController: UITableViewController { case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel): accountListCell.viewModel = accountViewModel case (let loadMoreCell as LoadMoreCell, let loadMoreViewModel as LoadMoreViewModel): - var contentConfiguration = loadMoreCell.defaultContentConfiguration() - - contentConfiguration.text = NSLocalizedString("load-more", comment: "") - - loadMoreCell.contentConfiguration = contentConfiguration + loadMoreCell.viewModel = loadMoreViewModel default: return nil } diff --git a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift index dce734f..bc91fec 100644 --- a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift +++ b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift @@ -1,7 +1,31 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import ServiceLayer -public class LoadMoreViewModel: ObservableObject { - @Published var loading = false +public struct LoadMoreViewModel { + public let loading: AnyPublisher + public let events: AnyPublisher, Never> + + private let loadMoreService: LoadMoreService + private let eventsSubject = PassthroughSubject, Never>() + private let loadingSubject = PassthroughSubject() + + init(loadMoreService: LoadMoreService) { + self.loadMoreService = loadMoreService + loading = loadingSubject.eraseToAnyPublisher() + events = eventsSubject.eraseToAnyPublisher() + } +} + +extension LoadMoreViewModel { + func loadMore() { + eventsSubject.send( + loadMoreService.request(direction: .down) + .handleEvents( + receiveSubscription: { _ in loadingSubject.send(true) }, + receiveCompletion: { _ in loadingSubject.send(false) }) + .map { _ in CollectionItemEvent.ignorableOutput } + .eraseToAnyPublisher()) + } } diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index 0c4780d..d026327 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -67,8 +67,8 @@ extension StatusListViewModel: CollectionViewModel { statusListService: statusListService .navigationService .contextStatusListService(id: configuration.status.displayStatus.id)))) - default: - break + case .loadMore: + loadMoreViewModel(item: item)?.loadMore() } } @@ -85,7 +85,7 @@ extension StatusListViewModel: CollectionViewModel { case .status: return statusViewModel(item: item) case .loadMore: - return LoadMoreViewModel() + return loadMoreViewModel(item: item) default: return nil } @@ -127,6 +127,33 @@ private extension StatusListViewModel { return statusViewModel } + func loadMoreViewModel(item: CollectionItemIdentifier) -> LoadMoreViewModel? { + guard let timelineItem = timelineItems[item], + case let .loadMore(loadMore) = timelineItem + else { return nil } + + if let cachedViewModel = viewModelCache[timelineItem]?.0 as? LoadMoreViewModel { + return cachedViewModel + } + + let loadMoreViewModel = LoadMoreViewModel( + loadMoreService: statusListService.navigationService.loadMoreService(loadMore: loadMore)) + + viewModelCache[timelineItem] = (loadMoreViewModel, loadMoreViewModel.events + .flatMap { $0 } + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + guard + let self = self, + let event = NavigationEvent($0) + else { return } + + self.navigationEventsSubject.send(event) + }) + + return loadMoreViewModel + } + func process(sections: [[Timeline.Item]]) { determineIfScrollPositionShouldBeMaintained(newSections: sections) diff --git a/Views/AccountListCell.swift b/Views/AccountListCell.swift index 68b4357..84a5db5 100644 --- a/Views/AccountListCell.swift +++ b/Views/AccountListCell.swift @@ -15,9 +15,12 @@ class AccountListCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() - let isPhoneIdiom = UIDevice.current.userInterfaceIdiom == .phone - - separatorInset.right = isPhoneIdiom ? 0 : layoutMargins.right - separatorInset.left = isPhoneIdiom ? 0 : layoutMargins.left + if UIDevice.current.userInterfaceIdiom == .phone { + separatorInset.left = 0 + separatorInset.right = 0 + } else { + separatorInset.left = layoutMargins.left + separatorInset.right = layoutMargins.right + } } } diff --git a/Views/LoadMoreCell.swift b/Views/LoadMoreCell.swift index 1a774a8..ba97beb 100644 --- a/Views/LoadMoreCell.swift +++ b/Views/LoadMoreCell.swift @@ -1,11 +1,26 @@ // Copyright © 2020 Metabolist. All rights reserved. import UIKit +import ViewModels class LoadMoreCell: UITableViewCell { + var viewModel: LoadMoreViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = LoadMoreContentConfiguration(viewModel: viewModel) + } + override func layoutSubviews() { super.layoutSubviews() - separatorInset.left = UIDevice.current.userInterfaceIdiom == .phone ? 0 : layoutMargins.left + if UIDevice.current.userInterfaceIdiom == .phone { + separatorInset.left = 0 + separatorInset.right = 0 + } else { + separatorInset.left = layoutMargins.left + separatorInset.right = layoutMargins.right + } } } diff --git a/Views/LoadMoreContentConfiguration.swift b/Views/LoadMoreContentConfiguration.swift new file mode 100644 index 0000000..0d95a3c --- /dev/null +++ b/Views/LoadMoreContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct LoadMoreContentConfiguration { + let viewModel: LoadMoreViewModel +} + +extension LoadMoreContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + LoadMoreView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> LoadMoreContentConfiguration { + self + } +} diff --git a/Views/LoadMoreView.swift b/Views/LoadMoreView.swift new file mode 100644 index 0000000..fef20ea --- /dev/null +++ b/Views/LoadMoreView.swift @@ -0,0 +1,91 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import UIKit + +class LoadMoreView: UIView { + private let label = UILabel() + private let activityIndicatorView = UIActivityIndicatorView() + private var loadMoreConfiguration: LoadMoreContentConfiguration + private var loadingCancellable: AnyCancellable? + + init(configuration: LoadMoreContentConfiguration) { + self.loadMoreConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension LoadMoreView: UIContentView { + var configuration: UIContentConfiguration { + get { loadMoreConfiguration } + set { + guard let loadMoreConfiguration = newValue as? LoadMoreContentConfiguration else { return } + + self.loadMoreConfiguration = loadMoreConfiguration + + applyLoadMoreConfiguration() + } + } +} + +private extension LoadMoreView { + func initialSetup() { + let leadingArrowImageView = UIImageView() + let trailingArrowImageView = UIImageView() + + for arrowImageView in [leadingArrowImageView, trailingArrowImageView] { + addSubview(arrowImageView) + arrowImageView.translatesAutoresizingMaskIntoConstraints = false + arrowImageView.image = UIImage( + systemName: "arrow.up.circle", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: UIFont.preferredFont(forTextStyle: .title2).pointSize)) + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + } + + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.font = .preferredFont(forTextStyle: .title2) + label.adjustsFontForContentSizeCategory = true + label.textColor = label.tintColor + label.text = NSLocalizedString("load-more", comment: "") + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + + addSubview(activityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.hidesWhenStopped = true + + NSLayoutConstraint.activate([ + leadingArrowImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + leadingArrowImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + leadingArrowImageView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), + label.leadingAnchor.constraint(equalTo: leadingArrowImageView.trailingAnchor), + label.topAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.topAnchor), + label.bottomAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.bottomAnchor), + label.trailingAnchor.constraint(equalTo: trailingArrowImageView.leadingAnchor), + trailingArrowImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + trailingArrowImageView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), + trailingArrowImageView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func applyLoadMoreConfiguration() { + loadingCancellable = loadMoreConfiguration.viewModel.loading.sink { [weak self] in + guard let self = self else { return } + + self.label.isHidden = $0 + $0 ? self.activityIndicatorView.startAnimating() : self.activityIndicatorView.stopAnimating() + } + } +} diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index ab26e88..b7266af 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -125,7 +125,7 @@ private extension StatusView { ]) for constraint in separatorConstraints { - constraint.constant = 1 / UIScreen.main.scale + constraint.constant = .hairline } avatarImageView.kf.indicatorType = .activity diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index d851380..aa76712 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -6,6 +6,7 @@ extension CGFloat { static let defaultSpacing: Self = 8 static let compactSpacing: Self = 4 static let defaultCornerRadius: Self = 8 + static let hairline = 1 / UIScreen.main.scale } extension TimeInterval {