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)
|
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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
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 {
|
||||||
|
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 */,
|
||||||
|
|
|
@ -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
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,83 +92,59 @@ 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)
|
||||||
.sink { [weak self] in
|
.sink { [weak self] in
|
||||||
guard
|
guard
|
||||||
let self = self,
|
let self = self,
|
||||||
let event = NavigationEvent($0)
|
let event = NavigationEvent($0)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
self.navigationEventsSubject.send(event)
|
self.navigationEventsSubject.send(event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class LoadMoreCell: UITableViewCell {
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue