Use timeline logic for profiles

This commit is contained in:
Justin Mazzocchi 2020-09-30 19:35:06 -07:00
parent 31156dd482
commit e7c6ac3f98
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 294 additions and 279 deletions

View file

@ -70,21 +70,11 @@ extension AccountRecord {
StatusRecord.self,
through: pinnedStatusJoins,
using: AccountPinnedStatusJoin.status)
static let statusJoins = hasMany(AccountStatusJoin.self)
var pinnedStatuses: QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(request(for: Self.pinnedStatuses))
}
func statuses(collection: ProfileCollection) -> QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(
request(for: Self.hasMany(
StatusRecord.self,
through: Self.statusJoins.filter(AccountStatusJoin.Columns.collection == collection.rawValue),
using: AccountStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc)))
}
init(account: Account) {
id = account.id
username = account.username

View file

@ -1,20 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
struct AccountStatusJoin: Codable, FetchableRecord, PersistableRecord {
let accountId: String
let statusId: String
let collection: ProfileCollection
static let status = belongsTo(StatusRecord.self)
}
extension AccountStatusJoin {
enum Columns {
static let accountId = Column(AccountStatusJoin.CodingKeys.accountId)
static let statusId = Column(AccountStatusJoin.CodingKeys.statusId)
static let collection = Column(AccountStatusJoin.CodingKeys.collection)
}
}

View file

@ -27,14 +27,14 @@ extension ContentDatabase {
t.column("emojis", .blob).notNull()
t.column("bot", .boolean).notNull()
t.column("discoverable", .boolean)
t.column("movedId", .text).references("accountRecord", column: "id")
t.column("movedId", .text).references("accountRecord")
}
try db.create(table: "statusRecord") { t in
t.column("id", .text).primaryKey(onConflict: .replace)
t.column("uri", .text).notNull()
t.column("createdAt", .datetime).notNull()
t.column("accountId", .text).notNull().references("accountRecord", column: "id")
t.column("accountId", .text).notNull().references("accountRecord")
t.column("content", .text).notNull()
t.column("visibility", .text).notNull()
t.column("sensitive", .boolean).notNull()
@ -50,7 +50,7 @@ extension ContentDatabase {
t.column("url", .text)
t.column("inReplyToId", .text)
t.column("inReplyToAccountId", .text)
t.column("reblogId", .text).references("statusRecord", column: "id")
t.column("reblogId", .text).references("statusRecord")
t.column("poll", .blob)
t.column("card", .blob)
t.column("language", .text)
@ -62,16 +62,20 @@ extension ContentDatabase {
t.column("pinned", .boolean)
}
try db.create(table: "timeline") { t in
try db.create(table: "timelineRecord") { t in
t.column("id", .text).primaryKey(onConflict: .replace)
t.column("listId", .text)
t.column("listTitle", .text).indexed().collate(.localizedCaseInsensitiveCompare)
t.column("tag", .text)
t.column("accountId", .text).references("accountRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("profileCollection", .text)
}
try db.create(table: "timelineStatusJoin") { t in
t.column("timelineId", .text).indexed().notNull()
.references("timeline", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("timelineRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text).indexed().notNull()
.references("statusRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("statusRecord", onDelete: .cascade, onUpdate: .cascade)
t.primaryKey(["timelineId", "statusId"], onConflict: .replace)
}
@ -87,9 +91,9 @@ extension ContentDatabase {
try db.create(table: "statusContextJoin") { t in
t.column("parentId", .text).indexed().notNull()
.references("statusRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("statusRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text).indexed().notNull()
.references("statusRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("statusRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("section", .text).indexed().notNull()
t.column("index", .integer).notNull()
@ -98,33 +102,23 @@ extension ContentDatabase {
try db.create(table: "accountPinnedStatusJoin") { t in
t.column("accountId", .text).indexed().notNull()
.references("accountRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("accountRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text).indexed().notNull()
.references("statusRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("statusRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("index", .integer).notNull()
t.primaryKey(["accountId", "statusId"], onConflict: .replace)
}
try db.create(table: "accountStatusJoin") { t in
t.column("accountId", .text).indexed().notNull()
.references("accountRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
t.column("statusId", .text).indexed().notNull()
.references("statusRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
t.column("collection", .text).indexed().notNull()
t.primaryKey(["accountId", "statusId", "collection"], onConflict: .replace)
}
try db.create(table: "accountList") { t in
t.column("id", .text).primaryKey(onConflict: .replace)
}
try db.create(table: "accountListJoin") { t in
t.column("accountId", .text).indexed().notNull()
.references("accountRecord", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("accountRecord", onDelete: .cascade, onUpdate: .cascade)
t.column("listId", .text).indexed().notNull()
.references("accountList", column: "id", onDelete: .cascade, onUpdate: .cascade)
.references("accountList", onDelete: .cascade, onUpdate: .cascade)
t.column("index", .integer).notNull()
t.primaryKey(["accountId", "listId"], onConflict: .replace)

View file

@ -99,21 +99,6 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func insert(
statuses: [Status],
accountID: String,
collection: ProfileCollection) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
for status in statuses {
try status.save($0)
try AccountStatusJoin(accountId: accountID, statusId: status.id, collection: collection).save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
try list.save($0)
@ -135,9 +120,9 @@ public extension ContentDatabase {
try Timeline.list(list).save($0)
}
try Timeline
.filter(!(Timeline.authenticatedDefaults.map(\.id) + lists.map(\.id)).contains(Timeline.Columns.id)
&& Timeline.Columns.listTitle != nil)
try TimelineRecord
.filter(!lists.map(\.id).contains(TimelineRecord.Columns.listId)
&& TimelineRecord.Columns.listTitle != nil)
.deleteAll($0)
}
.ignoreOutput()
@ -151,7 +136,7 @@ public extension ContentDatabase {
}
func deleteList(id: String) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher(updates: Timeline.filter(Timeline.Columns.id == id).deleteAll)
databaseWriter.writePublisher(updates: TimelineRecord.filter(TimelineRecord.Columns.listId == id).deleteAll)
.ignoreOutput()
.eraseToAnyPublisher()
}
@ -181,10 +166,21 @@ public extension ContentDatabase {
}
func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> {
ValueObservation.tracking(timeline.statuses.fetchAll)
ValueObservation.tracking { db -> [[StatusInfo]] in
let statuses = try TimelineRecord(timeline: timeline).statuses.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) ?? []
return [pinnedStatuses, statuses]
} else {
return [statuses]
}
}
.removeDuplicates()
.map { $0.map { $0.map(Status.init(info:)) } }
.publisher(in: databaseWriter)
.map { [$0.map(Status.init(info:))] }
.eraseToAnyPublisher()
}
@ -201,40 +197,17 @@ public extension ContentDatabase {
return [ancestors, [parent], descendants]
}
.removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map { $0.map(Status.init(info:)) } }
.eraseToAnyPublisher()
}
func statusesObservation(
accountID: String,
collection: ProfileCollection) -> AnyPublisher<[[Status]], Error> {
ValueObservation.tracking { db -> [[StatusInfo]] in
guard let accountRecord = try AccountRecord
.filter(AccountRecord.Columns.id == accountID)
.fetchOne(db) else {
return []
}
let statuses = try accountRecord.statuses(collection: collection).fetchAll(db)
if case .statuses = collection {
return [try accountRecord.pinnedStatuses.fetchAll(db), statuses]
} else {
return [statuses]
}
}
.removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map { $0.map(Status.init(info:)) } }
.eraseToAnyPublisher()
}
func listsObservation() -> AnyPublisher<[Timeline], Error> {
ValueObservation.tracking(Timeline.filter(Timeline.Columns.listTitle != nil)
.order(Timeline.Columns.listTitle.asc)
ValueObservation.tracking(TimelineRecord.filter(TimelineRecord.Columns.listId != nil)
.order(TimelineRecord.Columns.listTitle.asc)
.fetchAll)
.removeDuplicates()
.map { $0.map(Timeline.init(record:)).compactMap { $0 } }
.publisher(in: databaseWriter)
.eraseToAnyPublisher()
}
@ -243,12 +216,12 @@ public extension ContentDatabase {
ValueObservation.tracking(
Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > date).fetchAll)
.removeDuplicates()
.publisher(in: databaseWriter)
.map {
guard let context = context else { return $0 }
return $0.filter { $0.context.contains(context) }
}
.publisher(in: databaseWriter)
.eraseToAnyPublisher()
}
@ -262,7 +235,6 @@ public extension ContentDatabase {
func accountObservation(id: String) -> AnyPublisher<Account?, Error> {
ValueObservation.tracking(AccountInfo.request(AccountRecord.filter(AccountRecord.Columns.id == id)).fetchOne)
.removeDuplicates()
.publisher(in: databaseWriter)
.map {
if let info = $0 {
return Account(info: info)
@ -270,14 +242,15 @@ public extension ContentDatabase {
return nil
}
}
.publisher(in: databaseWriter)
.eraseToAnyPublisher()
}
func accountListObservation(_ list: AccountList) -> AnyPublisher<[Account], Error> {
ValueObservation.tracking(list.accounts.fetchAll)
.removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map(Account.init(info:)) }
.publisher(in: databaseWriter)
.eraseToAnyPublisher()
}
}
@ -289,7 +262,7 @@ private extension ContentDatabase {
func clean() throws {
try databaseWriter.write {
try Timeline.deleteAll($0)
try TimelineRecord.deleteAll($0)
try StatusRecord.deleteAll($0)
try AccountRecord.deleteAll($0)
try AccountList.deleteAll($0)

View file

@ -0,0 +1,77 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
struct TimelineRecord: Codable, Hashable {
let id: String
let listId: String?
let listTitle: String?
let tag: String?
let accountId: String?
let profileCollection: ProfileCollection?
}
extension TimelineRecord: FetchableRecord, PersistableRecord {
static func databaseJSONDecoder(for column: String) -> JSONDecoder {
MastodonDecoder()
}
static func databaseJSONEncoder(for column: String) -> JSONEncoder {
MastodonEncoder()
}
}
extension TimelineRecord {
enum Columns {
static let id = Column(TimelineRecord.CodingKeys.id)
static let listId = Column(TimelineRecord.CodingKeys.listId)
static let listTitle = Column(TimelineRecord.CodingKeys.listTitle)
static let tag = Column(TimelineRecord.CodingKeys.tag)
static let accountId = Column(TimelineRecord.CodingKeys.accountId)
static let profileCollection = Column(TimelineRecord.CodingKeys.profileCollection)
}
static let statusJoins = hasMany(TimelineStatusJoin.self)
static let statuses = hasMany(
StatusRecord.self,
through: statusJoins,
using: TimelineStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc)
var statuses: QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(request(for: Self.statuses))
}
init(timeline: Timeline) {
id = timeline.id
switch timeline {
case .home, .local, .federated:
listId = nil
listTitle = nil
tag = nil
accountId = nil
profileCollection = nil
case let .list(list):
listId = list.id
listTitle = list.title
tag = nil
accountId = nil
profileCollection = nil
case let .tag(tag):
listId = nil
listTitle = nil
self.tag = tag
accountId = nil
profileCollection = nil
case let .profile(accountId, profileCollection):
listId = nil
listTitle = nil
tag = nil
self.accountId = accountId
self.profileCollection = profileCollection
}
}
}

View file

@ -1,6 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Mastodon
public enum Timeline: Hashable {
case home
@ -8,6 +9,7 @@ public enum Timeline: Hashable {
case federated
case list(List)
case tag(String)
case profile(accountId: String, profileCollection: ProfileCollection)
}
public extension Timeline {
@ -25,9 +27,11 @@ extension Timeline: Identifiable {
case .federated:
return "federated"
case let .list(list):
return list.id
return "list-".appending(list.id)
case let .tag(tag):
return "#".appending(tag).lowercased()
return "tag-".appending(tag).lowercased()
case let .profile(accountId, profileCollection):
return "profile-\(accountId)-\(profileCollection)"
}
}
}

View file

@ -4,48 +4,32 @@ import Foundation
import GRDB
import Mastodon
extension Timeline: FetchableRecord, PersistableRecord {
enum Columns: String, ColumnExpression {
case id
case listTitle
}
public init(row: Row) {
switch (row[Columns.id] as String, row[Columns.listTitle] as String?) {
case (Timeline.home.id, _):
self = .home
case (Timeline.local.id, _):
self = .local
case (Timeline.federated.id, _):
self = .federated
case (let id, .some(let title)):
self = .list(List(id: id, title: title))
default:
var tag: String = row[Columns.id]
tag.removeFirst()
self = .tag(tag)
}
}
public func encode(to container: inout PersistenceContainer) {
container[Columns.id] = id
if case let .list(list) = self {
container[Columns.listTitle] = list.title
}
}
}
extension Timeline {
static let statusJoins = hasMany(TimelineStatusJoin.self)
static let statuses = hasMany(
StatusRecord.self,
through: statusJoins,
using: TimelineStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc)
func save(_ db: Database) throws {
try TimelineRecord(timeline: self).save(db)
}
var statuses: QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(request(for: Self.statuses))
init?(record: TimelineRecord) {
switch (record.id,
record.listId,
record.listTitle,
record.tag,
record.accountId,
record.profileCollection) {
case (Timeline.home.id, _, _, _, _, _):
self = .home
case (Timeline.local.id, _, _, _, _, _):
self = .local
case (Timeline.federated.id, _, _, _, _, _):
self = .federated
case (_, .some(let listId), .some(let listTitle), _, _, _):
self = .list(List(id: listId, title: listTitle))
case (_, _, _, .some(let tag), _, _):
self = .tag(tag)
case (_, _, _, _, .some(let accountId), .some(let profileCollection)):
self = .profile(accountId: accountId, profileCollection: profileCollection)
default:
return nil
}
}
}

View file

@ -1,21 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Mastodon
public extension Timeline {
var endpoint: StatusesEndpoint {
switch self {
case .home:
return .timelinesHome
case .local:
return .timelinesPublic(local: true)
case .federated:
return .timelinesPublic(local: false)
case let .list(list):
return .timelinesList(id: list.id)
case let .tag(tag):
return .timelinesTag(tag)
}
}
}

View file

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

View file

@ -7,7 +7,7 @@ import Mastodon
import MastodonAPI
public struct ProfileService {
public let accountService: AnyPublisher<AccountService, Error>
public let accountServicePublisher: AnyPublisher<AccountService, Error>
private let accountID: String
private let mastodonAPIClient: MastodonAPIClient
@ -45,18 +45,16 @@ public struct ProfileService {
.eraseToAnyPublisher()
}
accountService = accountPublisher
accountServicePublisher = accountPublisher
.map { AccountService(account: $0, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) }
.eraseToAnyPublisher()
}
}
public extension ProfileService {
func statusListService(
collectionPublisher: CurrentValueSubject<ProfileCollection, Never>) -> StatusListService {
func statusListService(profileCollection: ProfileCollection) -> StatusListService {
StatusListService(
accountID: accountID,
collection: collectionPublisher,
timeline: .profile(accountId: accountID, profileCollection: profileCollection),
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}

View file

@ -21,15 +21,6 @@ public struct StatusListService {
extension StatusListService {
init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
let filterContext: Filter.Context
switch timeline {
case .home, .list:
filterContext = .home
case .local, .federated, .tag:
filterContext = .public
}
var title: String?
if case let .tag(tag) = timeline {
@ -46,7 +37,7 @@ extension StatusListService {
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
filterContext: filterContext,
filterContext: timeline.filterContext,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in
mastodonAPIClient.pagedRequest(timeline.endpoint, maxID: maxID, minID: minID)
@ -78,59 +69,6 @@ extension StatusListService {
.eraseToAnyPublisher()
}
}
init(
accountID: String,
collection: CurrentValueSubject<ProfileCollection, Never>,
mastodonAPIClient: MastodonAPIClient,
contentDatabase: ContentDatabase) {
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init(
statusSections: collection
.flatMap { contentDatabase.statusesObservation(accountID: accountID, collection: $0) }
.eraseToAnyPublisher(),
nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil,
title: nil,
navigationService: NavigationService(
status: nil,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase),
filterContext: .account,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in
let excludeReplies: Bool
let onlyMedia: Bool
switch collection.value {
case .statuses:
excludeReplies = true
onlyMedia = false
case .statusesAndReplies:
excludeReplies = false
onlyMedia = false
case .media:
excludeReplies = true
onlyMedia = true
}
let endpoint = StatusesEndpoint.accountsStatuses(
id: accountID,
excludeReplies: excludeReplies,
onlyMedia: onlyMedia,
pinned: false)
return mastodonAPIClient.pagedRequest(endpoint, maxID: maxID, minID: minID)
.handleEvents(receiveOutput: { nextPageMaxIDsSubject.send($0.info.maxID) })
.flatMap {
contentDatabase.insert(
statuses: $0.result,
accountID: accountID,
collection: collection.value)
}
.eraseToAnyPublisher()
}
}
}
public extension StatusListService {
@ -142,3 +80,52 @@ public extension StatusListService {
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext)
}
}
private extension Timeline {
var endpoint: StatusesEndpoint {
switch self {
case .home:
return .timelinesHome
case .local:
return .timelinesPublic(local: true)
case .federated:
return .timelinesPublic(local: false)
case let .list(list):
return .timelinesList(id: list.id)
case let .tag(tag):
return .timelinesTag(tag)
case let .profile(accountId, profileCollection):
let excludeReplies: Bool
let onlyMedia: Bool
switch profileCollection {
case .statuses:
excludeReplies = true
onlyMedia = false
case .statusesAndReplies:
excludeReplies = false
onlyMedia = false
case .media:
excludeReplies = true
onlyMedia = true
}
return .accountsStatuses(
id: accountId,
excludeReplies: excludeReplies,
onlyMedia: onlyMedia,
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

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

View file

@ -53,7 +53,7 @@ public extension NavigationViewModel {
switch timeline {
case .home, .list:
return identification.identity.handle
case .local, .federated, .tag:
case .local, .federated, .tag, .profile:
return identification.identity.instance?.uri ?? ""
}
}

View file

@ -5,57 +5,86 @@ import Foundation
import Mastodon
import ServiceLayer
public class ProfileViewModel: StatusListViewModel {
final public class ProfileViewModel {
@Published public private(set) var accountViewModel: AccountViewModel?
@Published public var collection = ProfileCollection.statuses
@Published public var alertItem: AlertItem?
private let profileService: ProfileService
private let collectionViewModel: CurrentValueSubject<StatusListViewModel, Never>
private var cancellables = Set<AnyCancellable>()
init(profileService: ProfileService) {
self.profileService = profileService
let collectionSubject = CurrentValueSubject<ProfileCollection, Never>(.statuses)
collectionViewModel = CurrentValueSubject(
StatusListViewModel(statusListService: profileService.statusListService(profileCollection: .statuses)))
super.init(
statusListService: profileService.statusListService(
collectionPublisher: collectionSubject))
$collection.sink(receiveValue: collectionSubject.send).store(in: &cancellables)
profileService.accountService
profileService.accountServicePublisher
.map(AccountViewModel.init(accountService:))
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$accountViewModel)
$collection.dropFirst()
.map(profileService.statusListService(profileCollection:))
.map(StatusListViewModel.init(statusListService:))
.sink { [weak self] in
guard let self = self else { return }
self.collectionViewModel.send($0)
$0.$alertItem.assign(to: &self.$alertItem)
}
.store(in: &cancellables)
}
}
public override var collectionItems: AnyPublisher<[[CollectionItem]], Never> {
// 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
super.collectionItems
.map {
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()
}.eraseToAnyPublisher()
}
public override var navigationEvents: AnyPublisher<NavigationEvent, Never> {
public var title: AnyPublisher<String?, Never> {
$accountViewModel.map { $0?.accountName }.eraseToAnyPublisher()
}
public var alertItems: AnyPublisher<AlertItem, Never> {
collectionViewModel.flatMap(\.alertItems).eraseToAnyPublisher()
}
public var loading: AnyPublisher<Bool, Never> {
collectionViewModel.flatMap(\.loading).eraseToAnyPublisher()
}
public var navigationEvents: AnyPublisher<NavigationEvent, Never> {
$accountViewModel.compactMap { $0 }
.flatMap(\.events)
.flatMap { $0 }
.map(NavigationEvent.init)
.compactMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.merge(with: super.navigationEvents)
.merge(with: collectionViewModel.flatMap(\.navigationEvents))
.eraseToAnyPublisher()
}
public override func request(maxID: String? = nil, minID: String? = nil) {
public var nextPageMaxID: String? {
collectionViewModel.value.nextPageMaxID
}
public var maintainScrollPositionOfItem: CollectionItem? {
collectionViewModel.value.maintainScrollPositionOfItem
}
public func request(maxID: String?, minID: String?) {
if case .statuses = collection, maxID == nil {
profileService.fetchPinnedStatuses()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
@ -63,10 +92,18 @@ public class ProfileViewModel: StatusListViewModel {
.store(in: &cancellables)
}
super.request(maxID: maxID, minID: minID)
collectionViewModel.value.request(maxID: maxID, minID: minID)
}
public override var title: AnyPublisher<String?, Never> {
$accountViewModel.map { $0?.accountName }.eraseToAnyPublisher()
public func itemSelected(_ item: CollectionItem) {
collectionViewModel.value.itemSelected(item)
}
public func canSelect(item: CollectionItem) -> Bool {
collectionViewModel.value.canSelect(item: item)
}
public func viewModel(item: CollectionItem) -> Any? {
collectionViewModel.value.viewModel(item: item)
}
}

View file

@ -5,7 +5,7 @@ import Foundation
import Mastodon
import ServiceLayer
public class StatusListViewModel: ObservableObject {
final public class StatusListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]()
@Published public var alertItem: AlertItem?
public private(set) var nextPageMaxID: String?
@ -40,13 +40,19 @@ public class StatusListViewModel: ObservableObject {
.sink { [weak self] in self?.nextPageMaxID = $0 }
.store(in: &cancellables)
}
}
extension StatusListViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() }
public var navigationEvents: AnyPublisher<NavigationEvent, Never> { navigationEventsSubject.eraseToAnyPublisher() }
public var title: AnyPublisher<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() }
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
public var navigationEvents: AnyPublisher<NavigationEvent, Never> { navigationEventsSubject.eraseToAnyPublisher() }
public func request(maxID: String? = nil, minID: String? = nil) {
statusListService.request(maxID: maxID, minID: minID)
.receive(on: DispatchQueue.main)
@ -57,12 +63,6 @@ public class StatusListViewModel: ObservableObject {
.sink { _ in }
.store(in: &cancellables)
}
}
extension StatusListViewModel: CollectionViewModel {
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
public func itemSelected(_ item: CollectionItem) {
switch item.kind {

View file

@ -81,7 +81,7 @@ private extension AccountHeaderView {
segmentedControl.insertSegment(
action: UIAction(title: collection.title) { [weak self] _ in
self?.viewModel?.collection = collection
self?.viewModel?.request()
self?.viewModel?.request(maxID: nil, minID: nil)
},
at: index,
animated: false)

View file

@ -1,7 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved.
import KingfisherSwiftUI
import enum Mastodon.Timeline
import SwiftUI
import ViewModels
@ -138,6 +137,8 @@ private extension Timeline {
return list.title
case let .tag(tag):
return "#" + tag
case .profile:
return ""
}
}
@ -148,6 +149,7 @@ private extension Timeline {
case .federated: return "globe"
case .list: return "scroll"
case .tag: return "number"
case .profile: return "person"
}
}
}