Timelines mega-refactor / load more WIP

This commit is contained in:
Justin Mazzocchi 2020-10-01 20:19:14 -07:00
parent 9bf23496ef
commit 4199ad96bf
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
21 changed files with 293 additions and 191 deletions

View File

@ -71,6 +71,13 @@ extension ContentDatabase {
t.column("profileCollection", .text) 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 try db.create(table: "timelineStatusJoin") { t in
t.column("timelineId", .text).indexed().notNull() t.column("timelineId", .text).indexed().notNull()
.references("timelineRecord", onDelete: .cascade) .references("timelineRecord", onDelete: .cascade)

View File

@ -165,39 +165,81 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> { // Awkward maps explained: https://github.com/groue/GRDB.swift#valueobservation-performance
ValueObservation.tracking { db -> [[StatusInfo]] in
let statuses = try TimelineRecord(timeline: timeline).statuses.fetchAll(db) 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 { if case let .profile(accountId, profileCollection) = timeline, profileCollection == .statuses {
let pinnedStatuses = try AccountRecord.filter(AccountRecord.Columns.id == accountId) 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 { } 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() .removeDuplicates()
.map { $0.map { $0.map(Status.init(info:)) } }
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> { func contextObservation(parentID: String) -> AnyPublisher<[[Timeline.Item]], Error> {
ValueObservation.tracking { db -> [[StatusInfo]] in ValueObservation.tracking { db -> ([[StatusInfo]], [Filter]) in
guard let parent = try StatusInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)) guard let parent = try StatusInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID))
.fetchOne(db) else { .fetchOne(db) else {
return [] return ([], [])
} }
let ancestors = try parent.record.ancestors.fetchAll(db) let ancestors = try parent.record.ancestors.fetchAll(db)
let descendants = try parent.record.descendants.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() .removeDuplicates()
.map { $0.map { $0.map(Status.init(info:)) } }
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -212,15 +254,10 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func activeFiltersObservation(date: Date, context: Filter.Context? = nil) -> AnyPublisher<[Filter], Error> { func activeFiltersObservation(date: Date) -> AnyPublisher<[Filter], Error> {
ValueObservation.tracking( ValueObservation.tracking(
Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > date).fetchAll) Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > date).fetchAll)
.removeDuplicates() .removeDuplicates()
.map {
guard let context = context else { return $0 }
return $0.filter { $0.context.contains(context) }
}
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View File

@ -21,4 +21,8 @@ extension StatusInfo {
static func request(_ request: QueryInterfaceRequest<StatusRecord>) -> QueryInterfaceRequest<Self> { static func request(_ request: QueryInterfaceRequest<StatusRecord>) -> QueryInterfaceRequest<Self> {
addingIncludes(request).asRequest(of: self) addingIncludes(request).asRequest(of: self)
} }
var filterableContent: String {
(record.filterableContent + (reblogRecord?.filterableContent ?? [])).joined(separator: " ")
}
} }

View File

@ -117,6 +117,10 @@ extension StatusRecord {
StatusInfo.request(request(for: Self.descendants)) StatusInfo.request(request(for: Self.descendants))
} }
var filterableContent: [String] {
[content.attributed.string, spoilerText] + (poll?.options.map(\.title) ?? [])
}
init(status: Status) { init(status: Status) {
id = status.id id = status.id
uri = status.uri uri = status.uri

View File

@ -39,11 +39,16 @@ extension TimelineRecord {
through: statusJoins, through: statusJoins,
using: TimelineStatusJoin.status) using: TimelineStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc) .order(StatusRecord.Columns.createdAt.desc)
static let loadMores = hasMany(LoadMore.self)
var statuses: QueryInterfaceRequest<StatusInfo> { var statuses: QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(request(for: Self.statuses)) StatusInfo.request(request(for: Self.statuses))
} }
var loadMores: QueryInterfaceRequest<LoadMore> {
request(for: Self.loadMores)
}
init(timeline: Timeline) { init(timeline: Timeline) {
id = timeline.id id = timeline.id

View File

@ -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)
}
}

View File

@ -15,6 +15,38 @@ public enum Timeline: Hashable {
public extension Timeline { public extension Timeline {
static let unauthenticatedDefaults: [Timeline] = [.local, .federated] static let unauthenticatedDefaults: [Timeline] = [.local, .federated]
static let authenticatedDefaults: [Timeline] = [.home, .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 { extension Timeline: Identifiable {

View File

@ -5,6 +5,16 @@ import GRDB
import Mastodon import Mastodon
extension Filter: FetchableRecord, PersistableRecord { 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 { enum Columns: String, ColumnExpression {
case id case id
case phrase case phrase
@ -14,11 +24,15 @@ extension Filter: FetchableRecord, PersistableRecord {
case wholeWord case wholeWord
} }
public static func databaseJSONDecoder(for column: String) -> JSONDecoder { static var active: QueryInterfaceRequest<Self> {
MastodonDecoder() filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > Date())
} }
}
public static func databaseJSONEncoder(for column: String) -> JSONEncoder {
MastodonEncoder() 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 }
} }
} }

View File

@ -2,13 +2,15 @@
import ViewModels import ViewModels
extension CollectionItem.Kind { extension CollectionItemIdentifier.Kind {
var cellClass: AnyClass { var cellClass: AnyClass {
switch self { switch self {
case .status: case .status:
return StatusListCell.self return StatusListCell.self
case .account: case .account:
return AccountListCell.self return AccountListCell.self
case .loadMore:
return LoadMoreCell.self
} }
} }
} }

View File

@ -109,14 +109,6 @@ public extension Status {
var displayStatus: Status { var displayStatus: Status {
reblog ?? self reblog ?? self
} }
var filterableContent: String {
[content.attributed.string,
spoilerText,
(poll?.options.map(\.title) ?? []).joined(separator: " "),
reblog?.filterableContent ?? ""]
.joined(separator: " ")
}
} }
extension Status: Hashable { extension Status: Hashable {

View File

@ -21,6 +21,7 @@
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.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 */; }; 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 */; }; D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; }; D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.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>"; }; 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>"; }; 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>"; }; 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>"; }; D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; 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>"; }; D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
@ -288,6 +290,7 @@
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D0B8510B25259E56004E0744 /* LoadMoreCell.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
@ -558,6 +561,7 @@
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */, D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */,
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */, D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */,

View File

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import DB
public typealias LoadMore = DB.LoadMore

View File

@ -7,7 +7,7 @@ import Mastodon
import MastodonAPI import MastodonAPI
public struct StatusListService { public struct StatusListService {
public let statusSections: AnyPublisher<[[Status]], Error> public let sections: AnyPublisher<[[Timeline.Item]], Error>
public let nextPageMaxIDs: AnyPublisher<String?, Never> public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let contextParentID: String? public let contextParentID: String?
public let title: String? public let title: String?
@ -29,7 +29,7 @@ extension StatusListService {
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>() let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init(statusSections: contentDatabase.statusesObservation(timeline: timeline), self.init(sections: contentDatabase.observation(timeline: timeline),
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(), nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil, contextParentID: nil,
title: title, title: title,
@ -48,7 +48,7 @@ extension StatusListService {
} }
init(statusID: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { init(statusID: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.init(statusSections: contentDatabase.contextObservation(parentID: statusID), self.init(sections: contentDatabase.contextObservation(parentID: statusID),
nextPageMaxIDs: Empty().eraseToAnyPublisher(), nextPageMaxIDs: Empty().eraseToAnyPublisher(),
contextParentID: statusID, contextParentID: statusID,
title: nil, title: nil,
@ -75,10 +75,6 @@ public extension StatusListService {
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> { func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
requestClosure(maxID, minID) requestClosure(maxID, minID)
} }
var filters: AnyPublisher<[Filter], Error> {
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
}
} }
private extension Timeline { private extension Timeline {
@ -117,15 +113,4 @@ private extension Timeline {
pinned: false) pinned: false)
} }
} }
var filterContext: Filter.Context {
switch self {
case .home, .list:
return .home
case .local, .federated, .tag:
return .public
case .profile:
return .account
}
}
} }

View File

@ -10,11 +10,11 @@ class TableViewController: UITableViewController {
private let loadingTableFooterView = LoadingTableFooterView() private let loadingTableFooterView = LoadingTableFooterView()
private let webfingerIndicatorView = WebfingerIndicatorView() private let webfingerIndicatorView = WebfingerIndicatorView()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]()
private let dataSourceQueue = private let dataSourceQueue =
DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue") 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 UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil } guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil }
@ -49,7 +49,7 @@ class TableViewController: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
for kind in CollectionItem.Kind.allCases { for kind in CollectionItemIdentifier.Kind.allCases {
tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass))
} }
@ -80,7 +80,7 @@ class TableViewController: UITableViewController {
forRowAt indexPath: IndexPath) { forRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return } 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 heightCache[item] = cell.frame.height
cellHeightCaches[tableView.frame.width] = heightCache cellHeightCaches[tableView.frame.width] = heightCache
@ -192,7 +192,7 @@ private extension TableViewController {
.store(in: &cancellables) .store(in: &cancellables)
} }
func update(items: [[CollectionItem]]) { func update(items: [[CollectionItemIdentifier]]) {
var offsetFromNavigationBar: CGFloat? var offsetFromNavigationBar: CGFloat?
if if

View File

@ -6,7 +6,7 @@ import Mastodon
import ServiceLayer import ServiceLayer
public final class AccountListViewModel: ObservableObject { public final class AccountListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]() @Published public private(set) var items = [[CollectionItemIdentifier]]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public let navigationEvents: AnyPublisher<NavigationEvent, Never> public let navigationEvents: AnyPublisher<NavigationEvent, Never>
public private(set) var nextPageMaxID: String? public private(set) var nextPageMaxID: String?
@ -27,7 +27,7 @@ public final class AccountListViewModel: ObservableObject {
self?.cleanViewModelCache(newAccountSections: $0) self?.cleanViewModelCache(newAccountSections: $0)
self?.accounts = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $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) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$items) .assign(to: &$items)
@ -39,7 +39,7 @@ public final class AccountListViewModel: ObservableObject {
} }
extension AccountListViewModel: CollectionViewModel { 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() } 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 loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
public var maintainScrollPositionOfItem: CollectionItem? { public var maintainScrollPositionOfItem: CollectionItemIdentifier? {
nil nil
} }
@ -62,7 +62,7 @@ extension AccountListViewModel: CollectionViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
public func itemSelected(_ item: CollectionItem) { public func itemSelected(_ item: CollectionItemIdentifier) {
switch item.kind { switch item.kind {
case .account: case .account:
let navigationService = accountListService.navigationService let navigationService = accountListService.navigationService
@ -80,11 +80,11 @@ extension AccountListViewModel: CollectionViewModel {
} }
} }
public func canSelect(item: CollectionItem) -> Bool { public func canSelect(item: CollectionItemIdentifier) -> Bool {
true true
} }
public func viewModel(item: CollectionItem) -> Any? { public func viewModel(item: CollectionItemIdentifier) -> Any? {
switch item.kind { switch item.kind {
case .account: case .account:
return accountViewModel(id: item.id) return accountViewModel(id: item.id)

View File

@ -4,15 +4,15 @@ import Combine
import Foundation import Foundation
public protocol CollectionViewModel { public protocol CollectionViewModel {
var collectionItems: AnyPublisher<[[CollectionItem]], Never> { get } var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> { get }
var title: AnyPublisher<String?, Never> { get } var title: AnyPublisher<String?, Never> { get }
var alertItems: AnyPublisher<AlertItem, Never> { get } var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get } var loading: AnyPublisher<Bool, Never> { get }
var navigationEvents: AnyPublisher<NavigationEvent, Never> { get } var navigationEvents: AnyPublisher<NavigationEvent, Never> { get }
var nextPageMaxID: String? { get } var nextPageMaxID: String? { get }
var maintainScrollPositionOfItem: CollectionItem? { get } var maintainScrollPositionOfItem: CollectionItemIdentifier? { get }
func request(maxID: String?, minID: String?) func request(maxID: String?, minID: String?)
func itemSelected(_ item: CollectionItem) func itemSelected(_ item: CollectionItemIdentifier)
func canSelect(item: CollectionItem) -> Bool func canSelect(item: CollectionItemIdentifier) -> Bool
func viewModel(item: CollectionItem) -> Any? func viewModel(item: CollectionItemIdentifier) -> Any?
} }

View File

@ -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
}
}

View File

@ -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 = [:]
}
}

View File

@ -39,18 +39,8 @@ final public class ProfileViewModel {
} }
extension ProfileViewModel: CollectionViewModel { extension ProfileViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { public var collectionItems: AnyPublisher<[[CollectionItemIdentifier]], Never> {
collectionViewModel.flatMap(\.collectionItems).map { collectionViewModel.flatMap(\.collectionItems).eraseToAnyPublisher()
$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 title: AnyPublisher<String?, Never> { public var title: AnyPublisher<String?, Never> {
@ -80,7 +70,7 @@ extension ProfileViewModel: CollectionViewModel {
collectionViewModel.value.nextPageMaxID collectionViewModel.value.nextPageMaxID
} }
public var maintainScrollPositionOfItem: CollectionItem? { public var maintainScrollPositionOfItem: CollectionItemIdentifier? {
collectionViewModel.value.maintainScrollPositionOfItem collectionViewModel.value.maintainScrollPositionOfItem
} }
@ -95,15 +85,15 @@ extension ProfileViewModel: CollectionViewModel {
collectionViewModel.value.request(maxID: maxID, minID: minID) collectionViewModel.value.request(maxID: maxID, minID: minID)
} }
public func itemSelected(_ item: CollectionItem) { public func itemSelected(_ item: CollectionItemIdentifier) {
collectionViewModel.value.itemSelected(item) collectionViewModel.value.itemSelected(item)
} }
public func canSelect(item: CollectionItem) -> Bool { public func canSelect(item: CollectionItemIdentifier) -> Bool {
collectionViewModel.value.canSelect(item: item) collectionViewModel.value.canSelect(item: item)
} }
public func viewModel(item: CollectionItem) -> Any? { public func viewModel(item: CollectionItemIdentifier) -> Any? {
collectionViewModel.value.viewModel(item: item) collectionViewModel.value.viewModel(item: item)
} }
} }

View File

@ -6,15 +6,15 @@ import Mastodon
import ServiceLayer import ServiceLayer
final public class StatusListViewModel: ObservableObject { final public class StatusListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]() @Published public private(set) var items = [[CollectionItemIdentifier]]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public private(set) var nextPageMaxID: String? 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 var flatStatusIDs = [String]()
private let statusListService: StatusListService 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 navigationEventsSubject = PassthroughSubject<NavigationEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>() private let loadingSubject = PassthroughSubject<Bool, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -22,18 +22,11 @@ final public class StatusListViewModel: ObservableObject {
init(statusListService: StatusListService) { init(statusListService: StatusListService) {
self.statusListService = statusListService self.statusListService = statusListService
statusListService.statusSections statusListService.sections
.combineLatest(statusListService.filters.map { $0.regularExpression() }) .handleEvents(receiveOutput: { [weak self] in self?.process(sections: $0) })
.map(Self.filter(statusSections:regularExpression:)) .map { $0.map { $0.map(CollectionItemIdentifier.init(timelineItem:)) } }
.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)
})
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } }
.assign(to: &$items) .assign(to: &$items)
statusListService.nextPageMaxIDs statusListService.nextPageMaxIDs
@ -43,7 +36,7 @@ final public class StatusListViewModel: ObservableObject {
} }
extension StatusListViewModel: CollectionViewModel { 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() } public var title: AnyPublisher<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() }
@ -64,23 +57,23 @@ extension StatusListViewModel: CollectionViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
public func itemSelected(_ item: CollectionItem) { public func itemSelected(_ item: CollectionItemIdentifier) {
switch item.kind { guard let timelineItem = timelineItems[item] else { return }
case .status:
let displayStatusID = statuses[item.id]?.displayStatus.id ?? item.id
switch timelineItem {
case let .status(configuration):
navigationEventsSubject.send( navigationEventsSubject.send(
.collectionNavigation( .collectionNavigation(
StatusListViewModel( StatusListViewModel(
statusListService: statusListService statusListService: statusListService
.navigationService .navigationService
.contextStatusListService(id: displayStatusID)))) .contextStatusListService(id: configuration.status.displayStatus.id))))
default: default:
break break
} }
} }
public func canSelect(item: CollectionItem) -> Bool { public func canSelect(item: CollectionItemIdentifier) -> Bool {
if case .status = item.kind, item.id == statusListService.contextParentID { if case .status = item.kind, item.id == statusListService.contextParentID {
return false return false
} }
@ -88,7 +81,7 @@ extension StatusListViewModel: CollectionViewModel {
return true return true
} }
public func viewModel(item: CollectionItem) -> Any? { public func viewModel(item: CollectionItemIdentifier) -> Any? {
switch item.kind { switch item.kind {
case .status: case .status:
return statusViewModel(item: item) return statusViewModel(item: item)
@ -99,27 +92,19 @@ extension StatusListViewModel: CollectionViewModel {
} }
private extension StatusListViewModel { private extension StatusListViewModel {
static func filter(statusSections: [[Status]], regularExpression: String?) -> [[Status]] { func statusViewModel(item: CollectionItemIdentifier) -> StatusViewModel? {
guard let regEx = regularExpression else { return statusSections } guard let timelineItem = timelineItems[item],
case let .status(configuration) = timelineItem
return statusSections.map { else { return nil }
$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 }
var statusViewModel: StatusViewModel var statusViewModel: StatusViewModel
if let cachedViewModel = statusViewModelCache[status]?.0 { if let cachedViewModel = viewModelCache[timelineItem]?.0 as? StatusViewModel {
statusViewModel = cachedViewModel statusViewModel = cachedViewModel
} else { } else {
statusViewModel = StatusViewModel( statusViewModel = StatusViewModel(
statusService: statusListService.navigationService.statusService(status: status)) statusService: statusListService.navigationService.statusService(status: configuration.status))
statusViewModelCache[status] = (statusViewModel, viewModelCache[timelineItem] = (statusViewModel,
statusViewModel.events statusViewModel.events
.flatMap { $0 } .flatMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
@ -133,49 +118,33 @@ private extension StatusListViewModel {
}) })
} }
statusViewModel.isContextParent = status.id == statusListService.contextParentID statusViewModel.isContextParent = configuration.status.id == statusListService.contextParentID
statusViewModel.isPinned = item.info[.pinned] != nil statusViewModel.isPinned = configuration.pinned
statusViewModel.isReplyInContext = isReplyInContext(status: status) statusViewModel.isReplyInContext = configuration.isReplyInContext
statusViewModel.hasReplyFollowing = hasReplyFollowing(status: status) statusViewModel.hasReplyFollowing = configuration.hasReplyFollowing
return statusViewModel 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 maintainScrollPositionOfItem = nil // clear old value
// Maintain scroll position of parent after initial load of context // Maintain scroll position of parent after initial load of context
if let contextParentID = contextParentID, flatStatusIDs == [contextParentID] || flatStatusIDs == [] { if let contextParentID = statusListService.contextParentID {
maintainScrollPositionOfItem = CollectionItem(id: contextParentID, kind: .status) 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
} }
} }

7
Views/LoadMoreCell.swift Normal file
View File

@ -0,0 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
class LoadMoreCell: UITableViewCell {
}