From 4199ad96bfff6112e5422451f450220d40b95417 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Thu, 1 Oct 2020 20:19:14 -0700 Subject: [PATCH] Timelines mega-refactor / load more WIP --- .../Content/ContentDatabase+Migration.swift | 7 + DB/Sources/DB/Content/ContentDatabase.swift | 73 +++++++--- DB/Sources/DB/Content/StatusInfo.swift | 4 + DB/Sources/DB/Content/StatusRecord.swift | 4 + DB/Sources/DB/Content/TimelineRecord.swift | 5 + DB/Sources/DB/Entities/LoadMore.swift | 27 ++++ DB/Sources/DB/Entities/Timeline.swift | 32 +++++ .../DB/Extensions/Filter+Extensions.swift | 26 +++- .../CollectionItemKind+Extensions.swift | 4 +- .../Sources/Mastodon/Entities/Status.swift | 8 -- Metatext.xcodeproj/project.pbxproj | 4 + .../ServiceLayer/Entities/LoadMore.swift | 5 + .../Services/StatusListService.swift | 21 +-- View Controllers/TableViewController.swift | 10 +- .../ViewModels/AccountListViewModel.swift | 14 +- .../ViewModels/CollectionViewModel.swift | 10 +- .../ViewModels/Entities/CollectionItem.swift | 24 ---- .../Entities/CollectionItemIdentifier.swift | 42 ++++++ .../Sources/ViewModels/ProfileViewModel.swift | 22 +-- .../ViewModels/StatusListViewModel.swift | 135 +++++++----------- Views/LoadMoreCell.swift | 7 + 21 files changed, 293 insertions(+), 191 deletions(-) create mode 100644 DB/Sources/DB/Entities/LoadMore.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Entities/LoadMore.swift delete mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionItem.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift create mode 100644 Views/LoadMoreCell.swift diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 36ad29b..ac15131 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -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) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 17c9a38..bdf9161 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -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() } diff --git a/DB/Sources/DB/Content/StatusInfo.swift b/DB/Sources/DB/Content/StatusInfo.swift index fdfebf2..84e64d5 100644 --- a/DB/Sources/DB/Content/StatusInfo.swift +++ b/DB/Sources/DB/Content/StatusInfo.swift @@ -21,4 +21,8 @@ extension StatusInfo { static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { addingIncludes(request).asRequest(of: self) } + + var filterableContent: String { + (record.filterableContent + (reblogRecord?.filterableContent ?? [])).joined(separator: " ") + } } diff --git a/DB/Sources/DB/Content/StatusRecord.swift b/DB/Sources/DB/Content/StatusRecord.swift index 001d648..f3c861a 100644 --- a/DB/Sources/DB/Content/StatusRecord.swift +++ b/DB/Sources/DB/Content/StatusRecord.swift @@ -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 diff --git a/DB/Sources/DB/Content/TimelineRecord.swift b/DB/Sources/DB/Content/TimelineRecord.swift index 3206f51..ebc3272 100644 --- a/DB/Sources/DB/Content/TimelineRecord.swift +++ b/DB/Sources/DB/Content/TimelineRecord.swift @@ -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.request(request(for: Self.statuses)) } + var loadMores: QueryInterfaceRequest { + request(for: Self.loadMores) + } + init(timeline: Timeline) { id = timeline.id diff --git a/DB/Sources/DB/Entities/LoadMore.swift b/DB/Sources/DB/Entities/LoadMore.swift new file mode 100644 index 0000000..e8aacb1 --- /dev/null +++ b/DB/Sources/DB/Entities/LoadMore.swift @@ -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) + } +} diff --git a/DB/Sources/DB/Entities/Timeline.swift b/DB/Sources/DB/Entities/Timeline.swift index 70839e6..a01ff3f 100644 --- a/DB/Sources/DB/Entities/Timeline.swift +++ b/DB/Sources/DB/Entities/Timeline.swift @@ -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 { diff --git a/DB/Sources/DB/Extensions/Filter+Extensions.swift b/DB/Sources/DB/Extensions/Filter+Extensions.swift index 46b31b2..456c0c2 100644 --- a/DB/Sources/DB/Extensions/Filter+Extensions.swift +++ b/DB/Sources/DB/Extensions/Filter+Extensions.swift @@ -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 { + 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 } } } diff --git a/Extensions/CollectionItemKind+Extensions.swift b/Extensions/CollectionItemKind+Extensions.swift index ebb98cd..ddcb1f5 100644 --- a/Extensions/CollectionItemKind+Extensions.swift +++ b/Extensions/CollectionItemKind+Extensions.swift @@ -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 } } } diff --git a/Mastodon/Sources/Mastodon/Entities/Status.swift b/Mastodon/Sources/Mastodon/Entities/Status.swift index 3a05bff..d7fa6b2 100644 --- a/Mastodon/Sources/Mastodon/Entities/Status.swift +++ b/Mastodon/Sources/Mastodon/Entities/Status.swift @@ -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 { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 6f963b5..45b2d56 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -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 = ""; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = ""; }; D0B7434825100DBB00C13DB6 /* StatusView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatusView.xib; sourceTree = ""; }; + D0B8510B25259E56004E0744 /* LoadMoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCell.swift; sourceTree = ""; }; D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = ""; }; D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/LoadMore.swift b/ServiceLayer/Sources/ServiceLayer/Entities/LoadMore.swift new file mode 100644 index 0000000..2c21fd6 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/LoadMore.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import DB + +public typealias LoadMore = DB.LoadMore diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift index e59f8a5..72b9cfd 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift @@ -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 public let contextParentID: String? public let title: String? @@ -29,7 +29,7 @@ extension StatusListService { let nextPageMaxIDsSubject = PassthroughSubject() - 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 { 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 - } - } } diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 5b4e8bc..9908f8a 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -10,11 +10,11 @@ class TableViewController: UITableViewController { private let loadingTableFooterView = LoadingTableFooterView() private let webfingerIndicatorView = WebfingerIndicatorView() private var cancellables = Set() - 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 = { + private lazy var dataSource: UITableViewDiffableDataSource = { 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 diff --git a/ViewModels/Sources/ViewModels/AccountListViewModel.swift b/ViewModels/Sources/ViewModels/AccountListViewModel.swift index 6023af1..ae5de75 100644 --- a/ViewModels/Sources/ViewModels/AccountListViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountListViewModel.swift @@ -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 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 { Just(nil).eraseToAnyPublisher() } @@ -47,7 +47,7 @@ extension AccountListViewModel: CollectionViewModel { public var loading: AnyPublisher { 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) diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index 5aad1f2..52f6499 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -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 { get } var alertItems: AnyPublisher { get } var loading: AnyPublisher { get } var navigationEvents: AnyPublisher { 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? } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift deleted file mode 100644 index c1627ec..0000000 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift +++ /dev/null @@ -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 - } -} diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift new file mode 100644 index 0000000..d010f45 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift @@ -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 = [:] + } +} diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index b712c89..26ef523 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -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 { @@ -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) } } diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index 550eb92..b0431e4 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -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() private let loadingSubject = PassthroughSubject() private var cancellables = Set() @@ -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 { 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 - } } diff --git a/Views/LoadMoreCell.swift b/Views/LoadMoreCell.swift new file mode 100644 index 0000000..ecad9cb --- /dev/null +++ b/Views/LoadMoreCell.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class LoadMoreCell: UITableViewCell { + +}