Timelines mega-refactor / load more WIP
This commit is contained in:
parent
9bf23496ef
commit
4199ad96bf
|
@ -71,6 +71,13 @@ extension ContentDatabase {
|
|||
t.column("profileCollection", .text)
|
||||
}
|
||||
|
||||
try db.create(table: "loadMore") { t in
|
||||
t.column("timelineId").notNull().references("timelineRecord", onDelete: .cascade)
|
||||
t.column("afterStatusId", .text).notNull()
|
||||
|
||||
t.primaryKey(["timelineId", "afterStatusId"], onConflict: .replace)
|
||||
}
|
||||
|
||||
try db.create(table: "timelineStatusJoin") { t in
|
||||
t.column("timelineId", .text).indexed().notNull()
|
||||
.references("timelineRecord", onDelete: .cascade)
|
||||
|
|
|
@ -165,39 +165,81 @@ public extension ContentDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> {
|
||||
ValueObservation.tracking { db -> [[StatusInfo]] in
|
||||
let statuses = try TimelineRecord(timeline: timeline).statuses.fetchAll(db)
|
||||
// Awkward maps explained: https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
|
||||
func observation(timeline: Timeline) -> AnyPublisher<[[Timeline.Item]], Error> {
|
||||
ValueObservation.tracking { db -> ([StatusInfo], [StatusInfo]?, [LoadMore], [Filter]) in
|
||||
let timelineRecord = TimelineRecord(timeline: timeline)
|
||||
let statuses = try timelineRecord.statuses.fetchAll(db)
|
||||
let loadMores = try timelineRecord.loadMores.fetchAll(db)
|
||||
let filters = try Filter.active.fetchAll(db)
|
||||
|
||||
if case let .profile(accountId, profileCollection) = timeline, profileCollection == .statuses {
|
||||
let pinnedStatuses = try AccountRecord.filter(AccountRecord.Columns.id == accountId)
|
||||
.fetchOne(db)?.pinnedStatuses.fetchAll(db) ?? []
|
||||
.fetchOne(db)?.pinnedStatuses.fetchAll(db)
|
||||
|
||||
return [pinnedStatuses, statuses]
|
||||
return (statuses, pinnedStatuses, loadMores, filters)
|
||||
} else {
|
||||
return [statuses]
|
||||
return (statuses, nil, loadMores, filters)
|
||||
}
|
||||
}
|
||||
.map { statuses, pinnedStatuses, loadMores, filters -> [[Timeline.Item]] in
|
||||
var timelineItems = statuses.filtered(filters: filters, context: timeline.filterContext)
|
||||
.map { Timeline.Item.status(.init(status: .init(info: $0))) }
|
||||
|
||||
for loadMore in loadMores {
|
||||
guard let index = timelineItems.firstIndex(where: {
|
||||
guard case let .status(configuration) = $0 else { return false }
|
||||
|
||||
return loadMore.afterStatusId < configuration.status.id
|
||||
}) else { continue }
|
||||
|
||||
timelineItems.insert(.loadMore(loadMore), at: index)
|
||||
}
|
||||
|
||||
if let pinnedStatuses = pinnedStatuses {
|
||||
return [pinnedStatuses.filtered(filters: filters, context: timeline.filterContext)
|
||||
.map { Timeline.Item.status(.init(status: .init(info: $0), pinned: true)) },
|
||||
timelineItems]
|
||||
} else {
|
||||
return [timelineItems]
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.map { $0.map { $0.map(Status.init(info:)) } }
|
||||
.publisher(in: databaseWriter)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> {
|
||||
ValueObservation.tracking { db -> [[StatusInfo]] in
|
||||
func contextObservation(parentID: String) -> AnyPublisher<[[Timeline.Item]], Error> {
|
||||
ValueObservation.tracking { db -> ([[StatusInfo]], [Filter]) in
|
||||
guard let parent = try StatusInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID))
|
||||
.fetchOne(db) else {
|
||||
return []
|
||||
return ([], [])
|
||||
}
|
||||
|
||||
let ancestors = try parent.record.ancestors.fetchAll(db)
|
||||
let descendants = try parent.record.descendants.fetchAll(db)
|
||||
|
||||
return [ancestors, [parent], descendants]
|
||||
return ([ancestors, [parent], descendants], try Filter.active.fetchAll(db))
|
||||
}
|
||||
.map { statusSections, filters in
|
||||
statusSections.map { section in
|
||||
section.filtered(filters: filters, context: .thread)
|
||||
.enumerated()
|
||||
.map { index, statusInfo in
|
||||
let isReplyInContext = index > 0
|
||||
&& section[index - 1].record.id == statusInfo.record.inReplyToId
|
||||
let hasReplyFollowing = section.count > index + 1
|
||||
&& section[index + 1].record.inReplyToId == statusInfo.record.id
|
||||
|
||||
return Timeline.Item.status(
|
||||
.init(status: .init(info: statusInfo),
|
||||
isReplyInContext: isReplyInContext,
|
||||
hasReplyFollowing: hasReplyFollowing))
|
||||
}
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.map { $0.map { $0.map(Status.init(info:)) } }
|
||||
.publisher(in: databaseWriter)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
@ -212,15 +254,10 @@ public extension ContentDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func activeFiltersObservation(date: Date, context: Filter.Context? = nil) -> AnyPublisher<[Filter], Error> {
|
||||
func activeFiltersObservation(date: Date) -> AnyPublisher<[Filter], Error> {
|
||||
ValueObservation.tracking(
|
||||
Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > date).fetchAll)
|
||||
.removeDuplicates()
|
||||
.map {
|
||||
guard let context = context else { return $0 }
|
||||
|
||||
return $0.filter { $0.context.contains(context) }
|
||||
}
|
||||
.publisher(in: databaseWriter)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
|
|
@ -21,4 +21,8 @@ extension StatusInfo {
|
|||
static func request(_ request: QueryInterfaceRequest<StatusRecord>) -> QueryInterfaceRequest<Self> {
|
||||
addingIncludes(request).asRequest(of: self)
|
||||
}
|
||||
|
||||
var filterableContent: String {
|
||||
(record.filterableContent + (reblogRecord?.filterableContent ?? [])).joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,6 +117,10 @@ extension StatusRecord {
|
|||
StatusInfo.request(request(for: Self.descendants))
|
||||
}
|
||||
|
||||
var filterableContent: [String] {
|
||||
[content.attributed.string, spoilerText] + (poll?.options.map(\.title) ?? [])
|
||||
}
|
||||
|
||||
init(status: Status) {
|
||||
id = status.id
|
||||
uri = status.uri
|
||||
|
|
|
@ -39,11 +39,16 @@ extension TimelineRecord {
|
|||
through: statusJoins,
|
||||
using: TimelineStatusJoin.status)
|
||||
.order(StatusRecord.Columns.createdAt.desc)
|
||||
static let loadMores = hasMany(LoadMore.self)
|
||||
|
||||
var statuses: QueryInterfaceRequest<StatusInfo> {
|
||||
StatusInfo.request(request(for: Self.statuses))
|
||||
}
|
||||
|
||||
var loadMores: QueryInterfaceRequest<LoadMore> {
|
||||
request(for: Self.loadMores)
|
||||
}
|
||||
|
||||
init(timeline: Timeline) {
|
||||
id = timeline.id
|
||||
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Mastodon
|
||||
|
||||
public struct LoadMore: Codable, Hashable {
|
||||
public let timelineId: String
|
||||
public let afterStatusId: String
|
||||
}
|
||||
|
||||
extension LoadMore: FetchableRecord, PersistableRecord {
|
||||
public static func databaseJSONDecoder(for column: String) -> JSONDecoder {
|
||||
MastodonDecoder()
|
||||
}
|
||||
|
||||
public static func databaseJSONEncoder(for column: String) -> JSONEncoder {
|
||||
MastodonEncoder()
|
||||
}
|
||||
}
|
||||
|
||||
extension LoadMore {
|
||||
enum Columns {
|
||||
static let timelineId = Column(LoadMore.CodingKeys.timelineId)
|
||||
static let belowStatusId = Column(LoadMore.CodingKeys.afterStatusId)
|
||||
}
|
||||
}
|
|
@ -15,6 +15,38 @@ public enum Timeline: Hashable {
|
|||
public extension Timeline {
|
||||
static let unauthenticatedDefaults: [Timeline] = [.local, .federated]
|
||||
static let authenticatedDefaults: [Timeline] = [.home, .local, .federated]
|
||||
|
||||
enum Item: Hashable {
|
||||
case status(StatusConfiguration)
|
||||
case loadMore(LoadMore)
|
||||
}
|
||||
|
||||
var filterContext: Filter.Context {
|
||||
switch self {
|
||||
case .home, .list:
|
||||
return .home
|
||||
case .local, .federated, .tag:
|
||||
return .public
|
||||
case .profile:
|
||||
return .account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Timeline.Item {
|
||||
struct StatusConfiguration: Hashable {
|
||||
public let status: Status
|
||||
public let pinned: Bool
|
||||
public let isReplyInContext: Bool
|
||||
public let hasReplyFollowing: Bool
|
||||
|
||||
init(status: Status, pinned: Bool = false, isReplyInContext: Bool = false, hasReplyFollowing: Bool = false) {
|
||||
self.status = status
|
||||
self.pinned = pinned
|
||||
self.isReplyInContext = isReplyInContext
|
||||
self.hasReplyFollowing = hasReplyFollowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Timeline: Identifiable {
|
||||
|
|
|
@ -5,6 +5,16 @@ import GRDB
|
|||
import Mastodon
|
||||
|
||||
extension Filter: FetchableRecord, PersistableRecord {
|
||||
public static func databaseJSONDecoder(for column: String) -> JSONDecoder {
|
||||
MastodonDecoder()
|
||||
}
|
||||
|
||||
public static func databaseJSONEncoder(for column: String) -> JSONEncoder {
|
||||
MastodonEncoder()
|
||||
}
|
||||
}
|
||||
|
||||
extension Filter {
|
||||
enum Columns: String, ColumnExpression {
|
||||
case id
|
||||
case phrase
|
||||
|
@ -14,11 +24,15 @@ extension Filter: FetchableRecord, PersistableRecord {
|
|||
case wholeWord
|
||||
}
|
||||
|
||||
public static func databaseJSONDecoder(for column: String) -> JSONDecoder {
|
||||
MastodonDecoder()
|
||||
}
|
||||
|
||||
public static func databaseJSONEncoder(for column: String) -> JSONEncoder {
|
||||
MastodonEncoder()
|
||||
static var active: QueryInterfaceRequest<Self> {
|
||||
filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > Date())
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == StatusInfo {
|
||||
func filtered(filters: [Filter], context: Filter.Context) -> Self {
|
||||
guard let regEx = filters.filter({ $0.context.contains(context) }).regularExpression() else { return self }
|
||||
|
||||
return filter { $0.filterableContent.range(of: regEx, options: [.regularExpression, .caseInsensitive]) == nil }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
import ViewModels
|
||||
|
||||
extension CollectionItem.Kind {
|
||||
extension CollectionItemIdentifier.Kind {
|
||||
var cellClass: AnyClass {
|
||||
switch self {
|
||||
case .status:
|
||||
return StatusListCell.self
|
||||
case .account:
|
||||
return AccountListCell.self
|
||||
case .loadMore:
|
||||
return LoadMoreCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,14 +109,6 @@ public extension Status {
|
|||
var displayStatus: Status {
|
||||
reblog ?? self
|
||||
}
|
||||
|
||||
var filterableContent: String {
|
||||
[content.attributed.string,
|
||||
spoilerText,
|
||||
(poll?.options.map(\.title) ?? []).joined(separator: " "),
|
||||
reblog?.filterableContent ?? ""]
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
extension Status: Hashable {
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
|
||||
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; };
|
||||
D0B7434925100DBB00C13DB6 /* StatusView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D0B7434825100DBB00C13DB6 /* StatusView.xib */; };
|
||||
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B8510B25259E56004E0744 /* LoadMoreCell.swift */; };
|
||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
|
||||
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
|
||||
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
|
||||
|
@ -112,6 +113,7 @@
|
|||
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
|
||||
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
|
||||
D0B7434825100DBB00C13DB6 /* StatusView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatusView.xib; sourceTree = "<group>"; };
|
||||
D0B8510B25259E56004E0744 /* LoadMoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCell.swift; sourceTree = "<group>"; };
|
||||
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
|
||||
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
||||
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
|
||||
|
@ -288,6 +290,7 @@
|
|||
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
|
||||
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
|
||||
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
|
||||
D0B8510B25259E56004E0744 /* LoadMoreCell.swift */,
|
||||
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
|
||||
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
|
||||
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
|
||||
|
@ -558,6 +561,7 @@
|
|||
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
|
||||
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
|
||||
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
|
||||
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
|
||||
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */,
|
||||
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,
|
||||
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import DB
|
||||
|
||||
public typealias LoadMore = DB.LoadMore
|
|
@ -7,7 +7,7 @@ import Mastodon
|
|||
import MastodonAPI
|
||||
|
||||
public struct StatusListService {
|
||||
public let statusSections: AnyPublisher<[[Status]], Error>
|
||||
public let sections: AnyPublisher<[[Timeline.Item]], Error>
|
||||
public let nextPageMaxIDs: AnyPublisher<String?, Never>
|
||||
public let contextParentID: String?
|
||||
public let title: String?
|
||||
|
@ -29,7 +29,7 @@ extension StatusListService {
|
|||
|
||||
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
|
||||
|
||||
self.init(statusSections: contentDatabase.statusesObservation(timeline: timeline),
|
||||
self.init(sections: contentDatabase.observation(timeline: timeline),
|
||||
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
|
||||
contextParentID: nil,
|
||||
title: title,
|
||||
|
@ -48,7 +48,7 @@ extension StatusListService {
|
|||
}
|
||||
|
||||
init(statusID: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||
self.init(statusSections: contentDatabase.contextObservation(parentID: statusID),
|
||||
self.init(sections: contentDatabase.contextObservation(parentID: statusID),
|
||||
nextPageMaxIDs: Empty().eraseToAnyPublisher(),
|
||||
contextParentID: statusID,
|
||||
title: nil,
|
||||
|
@ -75,10 +75,6 @@ public extension StatusListService {
|
|||
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
|
||||
requestClosure(maxID, minID)
|
||||
}
|
||||
|
||||
var filters: AnyPublisher<[Filter], Error> {
|
||||
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Timeline {
|
||||
|
@ -117,15 +113,4 @@ private extension Timeline {
|
|||
pinned: false)
|
||||
}
|
||||
}
|
||||
|
||||
var filterContext: Filter.Context {
|
||||
switch self {
|
||||
case .home, .list:
|
||||
return .home
|
||||
case .local, .federated, .tag:
|
||||
return .public
|
||||
case .profile:
|
||||
return .account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,11 +10,11 @@ class TableViewController: UITableViewController {
|
|||
private let loadingTableFooterView = LoadingTableFooterView()
|
||||
private let webfingerIndicatorView = WebfingerIndicatorView()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
|
||||
private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]()
|
||||
private let dataSourceQueue =
|
||||
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue")
|
||||
|
||||
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItem> = {
|
||||
private lazy var dataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> = {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil }
|
||||
|
||||
|
@ -49,7 +49,7 @@ class TableViewController: UITableViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
for kind in CollectionItem.Kind.allCases {
|
||||
for kind in CollectionItemIdentifier.Kind.allCases {
|
||||
tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass))
|
||||
}
|
||||
|
||||
|
@ -80,7 +80,7 @@ class TableViewController: UITableViewController {
|
|||
forRowAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]()
|
||||
var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItemIdentifier: CGFloat]()
|
||||
|
||||
heightCache[item] = cell.frame.height
|
||||
cellHeightCaches[tableView.frame.width] = heightCache
|
||||
|
@ -192,7 +192,7 @@ private extension TableViewController {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func update(items: [[CollectionItem]]) {
|
||||
func update(items: [[CollectionItemIdentifier]]) {
|
||||
var offsetFromNavigationBar: CGFloat?
|
||||
|
||||
if
|
||||
|
|
|
@ -6,7 +6,7 @@ import Mastodon
|
|||
import ServiceLayer
|
||||
|
||||
public final class AccountListViewModel: ObservableObject {
|
||||
@Published public private(set) var items = [[CollectionItem]]()
|
||||
@Published public private(set) var items = [[CollectionItemIdentifier]]()
|
||||
@Published public var alertItem: AlertItem?
|
||||
public let navigationEvents: AnyPublisher<NavigationEvent, Never>
|
||||
public private(set) var nextPageMaxID: String?
|
||||
|
@ -27,7 +27,7 @@ public final class AccountListViewModel: ObservableObject {
|
|||
self?.cleanViewModelCache(newAccountSections: $0)
|
||||
self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
|
||||
})
|
||||
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .account) } } }
|
||||
.map { $0.map { $0.map(CollectionItemIdentifier.init(account:)) } }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.assign(to: &$items)
|
||||
|
@ -39,7 +39,7 @@ public final class AccountListViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
extension AccountListViewModel: CollectionViewModel {
|
||||
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() }
|
||||
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() }
|
||||
|
||||
public var title: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
|
||||
|
||||
|
@ -47,7 +47,7 @@ extension AccountListViewModel: CollectionViewModel {
|
|||
|
||||
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
|
||||
|
||||
public var maintainScrollPositionOfItem: CollectionItem? {
|
||||
public var maintainScrollPositionOfItem: CollectionItemIdentifier? {
|
||||
nil
|
||||
}
|
||||
|
||||
|
@ -62,7 +62,7 @@ extension AccountListViewModel: CollectionViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
public func itemSelected(_ item: CollectionItem) {
|
||||
public func itemSelected(_ item: CollectionItemIdentifier) {
|
||||
switch item.kind {
|
||||
case .account:
|
||||
let navigationService = accountListService.navigationService
|
||||
|
@ -80,11 +80,11 @@ extension AccountListViewModel: CollectionViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
public func canSelect(item: CollectionItem) -> Bool {
|
||||
public func canSelect(item: CollectionItemIdentifier) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public func viewModel(item: CollectionItem) -> Any? {
|
||||
public func viewModel(item: CollectionItemIdentifier) -> Any? {
|
||||
switch item.kind {
|
||||
case .account:
|
||||
return accountViewModel(id: item.id)
|
||||
|
|
|
@ -4,15 +4,15 @@ import Combine
|
|||
import Foundation
|
||||
|
||||
public protocol CollectionViewModel {
|
||||
var collectionItems: AnyPublisher<[[CollectionItem]], Never> { get }
|
||||
var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { get }
|
||||
var title: AnyPublisher<String?, Never> { get }
|
||||
var alertItems: AnyPublisher<AlertItem, Never> { get }
|
||||
var loading: AnyPublisher<Bool, Never> { get }
|
||||
var navigationEvents: AnyPublisher<NavigationEvent, Never> { get }
|
||||
var nextPageMaxID: String? { get }
|
||||
var maintainScrollPositionOfItem: CollectionItem? { get }
|
||||
var maintainScrollPositionOfItem: CollectionItemIdentifier? { get }
|
||||
func request(maxID: String?, minID: String?)
|
||||
func itemSelected(_ item: CollectionItem)
|
||||
func canSelect(item: CollectionItem) -> Bool
|
||||
func viewModel(item: CollectionItem) -> Any?
|
||||
func itemSelected(_ item: CollectionItemIdentifier)
|
||||
func canSelect(item: CollectionItemIdentifier) -> Bool
|
||||
func viewModel(item: CollectionItemIdentifier) -> Any?
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
public struct CollectionItem: Hashable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let info: [InfoKey: AnyHashable]
|
||||
|
||||
init(id: String, kind: Kind, info: [InfoKey: AnyHashable]? = nil) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.info = info ?? [InfoKey: AnyHashable]()
|
||||
}
|
||||
}
|
||||
|
||||
public extension CollectionItem {
|
||||
enum Kind: Hashable, CaseIterable {
|
||||
case status
|
||||
case account
|
||||
}
|
||||
|
||||
enum InfoKey {
|
||||
case pinned
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Mastodon
|
||||
|
||||
public struct CollectionItemIdentifier: Hashable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let info: [InfoKey: AnyHashable]
|
||||
}
|
||||
|
||||
public extension CollectionItemIdentifier {
|
||||
enum Kind: Hashable, CaseIterable {
|
||||
case status
|
||||
case loadMore
|
||||
case account
|
||||
}
|
||||
|
||||
enum InfoKey {
|
||||
case pinned
|
||||
}
|
||||
}
|
||||
|
||||
extension CollectionItemIdentifier {
|
||||
init(timelineItem: Timeline.Item) {
|
||||
switch timelineItem {
|
||||
case let .status(configuration):
|
||||
id = configuration.status.id
|
||||
kind = .status
|
||||
info = configuration.pinned ? [.pinned: true] : [:]
|
||||
case let .loadMore(loadMore):
|
||||
id = loadMore.afterStatusId
|
||||
kind = .loadMore
|
||||
info = [:]
|
||||
}
|
||||
}
|
||||
|
||||
init(account: Account) {
|
||||
id = account.id
|
||||
kind = .account
|
||||
info = [:]
|
||||
}
|
||||
}
|
|
@ -39,18 +39,8 @@ final public class ProfileViewModel {
|
|||
}
|
||||
|
||||
extension ProfileViewModel: CollectionViewModel {
|
||||
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> {
|
||||
collectionViewModel.flatMap(\.collectionItems).map {
|
||||
$0.enumerated().map { [weak self] in
|
||||
if let self = self, self.collection == .statuses, $0 == 0 {
|
||||
// The pinned key is added to the info of collection items in the first section
|
||||
// so a diffable data source can potentially render it in both sections
|
||||
return $1.map { .init(id: $0.id, kind: $0.kind, info: [.pinned: true]) }
|
||||
} else {
|
||||
return $1
|
||||
}
|
||||
}
|
||||
}.eraseToAnyPublisher()
|
||||
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> {
|
||||
collectionViewModel.flatMap(\.collectionItems).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public var title: AnyPublisher<String?, Never> {
|
||||
|
@ -80,7 +70,7 @@ extension ProfileViewModel: CollectionViewModel {
|
|||
collectionViewModel.value.nextPageMaxID
|
||||
}
|
||||
|
||||
public var maintainScrollPositionOfItem: CollectionItem? {
|
||||
public var maintainScrollPositionOfItem: CollectionItemIdentifier? {
|
||||
collectionViewModel.value.maintainScrollPositionOfItem
|
||||
}
|
||||
|
||||
|
@ -95,15 +85,15 @@ extension ProfileViewModel: CollectionViewModel {
|
|||
collectionViewModel.value.request(maxID: maxID, minID: minID)
|
||||
}
|
||||
|
||||
public func itemSelected(_ item: CollectionItem) {
|
||||
public func itemSelected(_ item: CollectionItemIdentifier) {
|
||||
collectionViewModel.value.itemSelected(item)
|
||||
}
|
||||
|
||||
public func canSelect(item: CollectionItem) -> Bool {
|
||||
public func canSelect(item: CollectionItemIdentifier) -> Bool {
|
||||
collectionViewModel.value.canSelect(item: item)
|
||||
}
|
||||
|
||||
public func viewModel(item: CollectionItem) -> Any? {
|
||||
public func viewModel(item: CollectionItemIdentifier) -> Any? {
|
||||
collectionViewModel.value.viewModel(item: item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,15 @@ import Mastodon
|
|||
import ServiceLayer
|
||||
|
||||
final public class StatusListViewModel: ObservableObject {
|
||||
@Published public private(set) var items = [[CollectionItem]]()
|
||||
@Published public private(set) var items = [[CollectionItemIdentifier]]()
|
||||
@Published public var alertItem: AlertItem?
|
||||
public private(set) var nextPageMaxID: String?
|
||||
public private(set) var maintainScrollPositionOfItem: CollectionItem?
|
||||
public private(set) var maintainScrollPositionOfItem: CollectionItemIdentifier?
|
||||
|
||||
private var statuses = [String: Status]()
|
||||
private var timelineItems = [CollectionItemIdentifier: Timeline.Item]()
|
||||
private var flatStatusIDs = [String]()
|
||||
private let statusListService: StatusListService
|
||||
private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]()
|
||||
private var viewModelCache = [Timeline.Item: (Any, AnyCancellable)]()
|
||||
private let navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
|
||||
private let loadingSubject = PassthroughSubject<Bool, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
@ -22,18 +22,11 @@ final public class StatusListViewModel: ObservableObject {
|
|||
init(statusListService: StatusListService) {
|
||||
self.statusListService = statusListService
|
||||
|
||||
statusListService.statusSections
|
||||
.combineLatest(statusListService.filters.map { $0.regularExpression() })
|
||||
.map(Self.filter(statusSections:regularExpression:))
|
||||
.handleEvents(receiveOutput: { [weak self] in
|
||||
self?.determineIfScrollPositionShouldBeMaintained(newStatusSections: $0)
|
||||
self?.cleanViewModelCache(newStatusSections: $0)
|
||||
self?.statuses = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) })
|
||||
self?.flatStatusIDs = $0.reduce([], +).map(\.id)
|
||||
})
|
||||
statusListService.sections
|
||||
.handleEvents(receiveOutput: { [weak self] in self?.process(sections: $0) })
|
||||
.map { $0.map { $0.map(CollectionItemIdentifier.init(timelineItem:)) } }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } }
|
||||
.assign(to: &$items)
|
||||
|
||||
statusListService.nextPageMaxIDs
|
||||
|
@ -43,7 +36,7 @@ final public class StatusListViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
extension StatusListViewModel: CollectionViewModel {
|
||||
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() }
|
||||
public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { $items.eraseToAnyPublisher() }
|
||||
|
||||
public var title: AnyPublisher<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() }
|
||||
|
||||
|
@ -64,23 +57,23 @@ extension StatusListViewModel: CollectionViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
public func itemSelected(_ item: CollectionItem) {
|
||||
switch item.kind {
|
||||
case .status:
|
||||
let displayStatusID = statuses[item.id]?.displayStatus.id ?? item.id
|
||||
public func itemSelected(_ item: CollectionItemIdentifier) {
|
||||
guard let timelineItem = timelineItems[item] else { return }
|
||||
|
||||
switch timelineItem {
|
||||
case let .status(configuration):
|
||||
navigationEventsSubject.send(
|
||||
.collectionNavigation(
|
||||
StatusListViewModel(
|
||||
statusListService: statusListService
|
||||
.navigationService
|
||||
.contextStatusListService(id: displayStatusID))))
|
||||
.contextStatusListService(id: configuration.status.displayStatus.id))))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func canSelect(item: CollectionItem) -> Bool {
|
||||
public func canSelect(item: CollectionItemIdentifier) -> Bool {
|
||||
if case .status = item.kind, item.id == statusListService.contextParentID {
|
||||
return false
|
||||
}
|
||||
|
@ -88,7 +81,7 @@ extension StatusListViewModel: CollectionViewModel {
|
|||
return true
|
||||
}
|
||||
|
||||
public func viewModel(item: CollectionItem) -> Any? {
|
||||
public func viewModel(item: CollectionItemIdentifier) -> Any? {
|
||||
switch item.kind {
|
||||
case .status:
|
||||
return statusViewModel(item: item)
|
||||
|
@ -99,83 +92,59 @@ extension StatusListViewModel: CollectionViewModel {
|
|||
}
|
||||
|
||||
private extension StatusListViewModel {
|
||||
static func filter(statusSections: [[Status]], regularExpression: String?) -> [[Status]] {
|
||||
guard let regEx = regularExpression else { return statusSections }
|
||||
|
||||
return statusSections.map {
|
||||
$0.filter { $0.filterableContent.range(of: regEx, options: [.regularExpression, .caseInsensitive]) == nil }
|
||||
}
|
||||
}
|
||||
|
||||
var contextParentID: String? { statusListService.contextParentID }
|
||||
|
||||
func statusViewModel(item: CollectionItem) -> StatusViewModel? {
|
||||
guard let status = statuses[item.id] else { return nil }
|
||||
func statusViewModel(item: CollectionItemIdentifier) -> StatusViewModel? {
|
||||
guard let timelineItem = timelineItems[item],
|
||||
case let .status(configuration) = timelineItem
|
||||
else { return nil }
|
||||
|
||||
var statusViewModel: StatusViewModel
|
||||
|
||||
if let cachedViewModel = statusViewModelCache[status]?.0 {
|
||||
if let cachedViewModel = viewModelCache[timelineItem]?.0 as? StatusViewModel {
|
||||
statusViewModel = cachedViewModel
|
||||
} else {
|
||||
statusViewModel = StatusViewModel(
|
||||
statusService: statusListService.navigationService.statusService(status: status))
|
||||
statusViewModelCache[status] = (statusViewModel,
|
||||
statusViewModel.events
|
||||
.flatMap { $0 }
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { [weak self] in
|
||||
guard
|
||||
let self = self,
|
||||
let event = NavigationEvent($0)
|
||||
else { return }
|
||||
statusService: statusListService.navigationService.statusService(status: configuration.status))
|
||||
viewModelCache[timelineItem] = (statusViewModel,
|
||||
statusViewModel.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)
|
||||
})
|
||||
self.navigationEventsSubject.send(event)
|
||||
})
|
||||
}
|
||||
|
||||
statusViewModel.isContextParent = status.id == statusListService.contextParentID
|
||||
statusViewModel.isPinned = item.info[.pinned] != nil
|
||||
statusViewModel.isReplyInContext = isReplyInContext(status: status)
|
||||
statusViewModel.hasReplyFollowing = hasReplyFollowing(status: status)
|
||||
statusViewModel.isContextParent = configuration.status.id == statusListService.contextParentID
|
||||
statusViewModel.isPinned = configuration.pinned
|
||||
statusViewModel.isReplyInContext = configuration.isReplyInContext
|
||||
statusViewModel.hasReplyFollowing = configuration.hasReplyFollowing
|
||||
|
||||
return statusViewModel
|
||||
}
|
||||
|
||||
func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) {
|
||||
func process(sections: [[Timeline.Item]]) {
|
||||
determineIfScrollPositionShouldBeMaintained(newSections: sections)
|
||||
|
||||
let timelineItemKeys = Set(sections.reduce([], +))
|
||||
|
||||
timelineItems = Dictionary(uniqueKeysWithValues: timelineItemKeys.map { (.init(timelineItem: $0), $0) })
|
||||
viewModelCache = viewModelCache.filter { timelineItemKeys.contains($0.key) }
|
||||
}
|
||||
|
||||
func determineIfScrollPositionShouldBeMaintained(newSections: [[Timeline.Item]]) {
|
||||
maintainScrollPositionOfItem = nil // clear old value
|
||||
|
||||
// Maintain scroll position of parent after initial load of context
|
||||
if let contextParentID = contextParentID, flatStatusIDs == [contextParentID] || flatStatusIDs == [] {
|
||||
maintainScrollPositionOfItem = CollectionItem(id: contextParentID, kind: .status)
|
||||
if let contextParentID = statusListService.contextParentID {
|
||||
let contextParentIdentifier = CollectionItemIdentifier(id: contextParentID, kind: .status, info: [:])
|
||||
|
||||
if items == [[], [contextParentIdentifier], []] || items.isEmpty {
|
||||
maintainScrollPositionOfItem = contextParentIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanViewModelCache(newStatusSections: [[Status]]) {
|
||||
let newStatuses = Set(newStatusSections.reduce([], +))
|
||||
|
||||
statusViewModelCache = statusViewModelCache.filter { newStatuses.contains($0.key) }
|
||||
}
|
||||
|
||||
func isReplyInContext(status: Status) -> Bool {
|
||||
guard
|
||||
let contextParentID = contextParentID,
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
index > 0
|
||||
else { return false }
|
||||
|
||||
let previousStatusID = flatStatusIDs[index - 1]
|
||||
|
||||
return previousStatusID != contextParentID && status.inReplyToId == previousStatusID
|
||||
}
|
||||
|
||||
func hasReplyFollowing(status: Status) -> Bool {
|
||||
guard
|
||||
let contextParentID = contextParentID,
|
||||
let index = flatStatusIDs.firstIndex(where: { $0 == status.id }),
|
||||
flatStatusIDs.count > index + 1,
|
||||
let nextStatus = statuses[flatStatusIDs[index + 1]]
|
||||
else { return false }
|
||||
|
||||
return status.id != contextParentID && nextStatus.inReplyToId == status.id
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class LoadMoreCell: UITableViewCell {
|
||||
|
||||
}
|
Loading…
Reference in New Issue