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, StatusRecord.self,
through: pinnedStatusJoins, through: pinnedStatusJoins,
using: AccountPinnedStatusJoin.status) using: AccountPinnedStatusJoin.status)
static let statusJoins = hasMany(AccountStatusJoin.self)
var pinnedStatuses: QueryInterfaceRequest<StatusInfo> { var pinnedStatuses: QueryInterfaceRequest<StatusInfo> {
StatusInfo.request(request(for: Self.pinnedStatuses)) 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) { init(account: Account) {
id = account.id id = account.id
username = account.username 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("emojis", .blob).notNull()
t.column("bot", .boolean).notNull() t.column("bot", .boolean).notNull()
t.column("discoverable", .boolean) 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 try db.create(table: "statusRecord") { t in
t.column("id", .text).primaryKey(onConflict: .replace) t.column("id", .text).primaryKey(onConflict: .replace)
t.column("uri", .text).notNull() t.column("uri", .text).notNull()
t.column("createdAt", .datetime).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("content", .text).notNull()
t.column("visibility", .text).notNull() t.column("visibility", .text).notNull()
t.column("sensitive", .boolean).notNull() t.column("sensitive", .boolean).notNull()
@ -50,7 +50,7 @@ extension ContentDatabase {
t.column("url", .text) t.column("url", .text)
t.column("inReplyToId", .text) t.column("inReplyToId", .text)
t.column("inReplyToAccountId", .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("poll", .blob)
t.column("card", .blob) t.column("card", .blob)
t.column("language", .text) t.column("language", .text)
@ -62,16 +62,20 @@ extension ContentDatabase {
t.column("pinned", .boolean) 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("id", .text).primaryKey(onConflict: .replace)
t.column("listId", .text)
t.column("listTitle", .text).indexed().collate(.localizedCaseInsensitiveCompare) 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 try db.create(table: "timelineStatusJoin") { t in
t.column("timelineId", .text).indexed().notNull() 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() 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) t.primaryKey(["timelineId", "statusId"], onConflict: .replace)
} }
@ -87,9 +91,9 @@ extension ContentDatabase {
try db.create(table: "statusContextJoin") { t in try db.create(table: "statusContextJoin") { t in
t.column("parentId", .text).indexed().notNull() 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() 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("section", .text).indexed().notNull()
t.column("index", .integer).notNull() t.column("index", .integer).notNull()
@ -98,33 +102,23 @@ extension ContentDatabase {
try db.create(table: "accountPinnedStatusJoin") { t in try db.create(table: "accountPinnedStatusJoin") { t in
t.column("accountId", .text).indexed().notNull() 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() 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.column("index", .integer).notNull()
t.primaryKey(["accountId", "statusId"], onConflict: .replace) 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 try db.create(table: "accountList") { t in
t.column("id", .text).primaryKey(onConflict: .replace) t.column("id", .text).primaryKey(onConflict: .replace)
} }
try db.create(table: "accountListJoin") { t in try db.create(table: "accountListJoin") { t in
t.column("accountId", .text).indexed().notNull() 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() 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.column("index", .integer).notNull()
t.primaryKey(["accountId", "listId"], onConflict: .replace) t.primaryKey(["accountId", "listId"], onConflict: .replace)

View file

@ -99,21 +99,6 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .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> { func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher { databaseWriter.writePublisher {
try list.save($0) try list.save($0)
@ -135,9 +120,9 @@ public extension ContentDatabase {
try Timeline.list(list).save($0) try Timeline.list(list).save($0)
} }
try Timeline try TimelineRecord
.filter(!(Timeline.authenticatedDefaults.map(\.id) + lists.map(\.id)).contains(Timeline.Columns.id) .filter(!lists.map(\.id).contains(TimelineRecord.Columns.listId)
&& Timeline.Columns.listTitle != nil) && TimelineRecord.Columns.listTitle != nil)
.deleteAll($0) .deleteAll($0)
} }
.ignoreOutput() .ignoreOutput()
@ -151,7 +136,7 @@ public extension ContentDatabase {
} }
func deleteList(id: String) -> AnyPublisher<Never, Error> { 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() .ignoreOutput()
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -181,11 +166,22 @@ public extension ContentDatabase {
} }
func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> { func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> {
ValueObservation.tracking(timeline.statuses.fetchAll) ValueObservation.tracking { db -> [[StatusInfo]] in
.removeDuplicates() let statuses = try TimelineRecord(timeline: timeline).statuses.fetchAll(db)
.publisher(in: databaseWriter)
.map { [$0.map(Status.init(info:))] } if case let .profile(accountId, profileCollection) = timeline, profileCollection == .statuses {
.eraseToAnyPublisher() 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)
.eraseToAnyPublisher()
} }
func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> { func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> {
@ -201,40 +197,17 @@ public extension ContentDatabase {
return [ancestors, [parent], descendants] return [ancestors, [parent], descendants]
} }
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map { $0.map(Status.init(info:)) } } .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) .publisher(in: databaseWriter)
.map { $0.map { $0.map(Status.init(info:)) } }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func listsObservation() -> AnyPublisher<[Timeline], Error> { func listsObservation() -> AnyPublisher<[Timeline], Error> {
ValueObservation.tracking(Timeline.filter(Timeline.Columns.listTitle != nil) ValueObservation.tracking(TimelineRecord.filter(TimelineRecord.Columns.listId != nil)
.order(Timeline.Columns.listTitle.asc) .order(TimelineRecord.Columns.listTitle.asc)
.fetchAll) .fetchAll)
.removeDuplicates() .removeDuplicates()
.map { $0.map(Timeline.init(record:)).compactMap { $0 } }
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -243,12 +216,12 @@ public extension ContentDatabase {
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()
.publisher(in: databaseWriter)
.map { .map {
guard let context = context else { return $0 } guard let context = context else { return $0 }
return $0.filter { $0.context.contains(context) } return $0.filter { $0.context.contains(context) }
} }
.publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -262,7 +235,6 @@ public extension ContentDatabase {
func accountObservation(id: String) -> AnyPublisher<Account?, Error> { func accountObservation(id: String) -> AnyPublisher<Account?, Error> {
ValueObservation.tracking(AccountInfo.request(AccountRecord.filter(AccountRecord.Columns.id == id)).fetchOne) ValueObservation.tracking(AccountInfo.request(AccountRecord.filter(AccountRecord.Columns.id == id)).fetchOne)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter)
.map { .map {
if let info = $0 { if let info = $0 {
return Account(info: info) return Account(info: info)
@ -270,14 +242,15 @@ public extension ContentDatabase {
return nil return nil
} }
} }
.publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func accountListObservation(_ list: AccountList) -> AnyPublisher<[Account], Error> { func accountListObservation(_ list: AccountList) -> AnyPublisher<[Account], Error> {
ValueObservation.tracking(list.accounts.fetchAll) ValueObservation.tracking(list.accounts.fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter)
.map { $0.map(Account.init(info:)) } .map { $0.map(Account.init(info:)) }
.publisher(in: databaseWriter)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
@ -289,7 +262,7 @@ private extension ContentDatabase {
func clean() throws { func clean() throws {
try databaseWriter.write { try databaseWriter.write {
try Timeline.deleteAll($0) try TimelineRecord.deleteAll($0)
try StatusRecord.deleteAll($0) try StatusRecord.deleteAll($0)
try AccountRecord.deleteAll($0) try AccountRecord.deleteAll($0)
try AccountList.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. // Copyright © 2020 Metabolist. All rights reserved.
import Foundation import Foundation
import Mastodon
public enum Timeline: Hashable { public enum Timeline: Hashable {
case home case home
@ -8,6 +9,7 @@ public enum Timeline: Hashable {
case federated case federated
case list(List) case list(List)
case tag(String) case tag(String)
case profile(accountId: String, profileCollection: ProfileCollection)
} }
public extension Timeline { public extension Timeline {
@ -25,9 +27,11 @@ extension Timeline: Identifiable {
case .federated: case .federated:
return "federated" return "federated"
case let .list(list): case let .list(list):
return list.id return "list-".appending(list.id)
case let .tag(tag): 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 GRDB
import Mastodon 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 { extension Timeline {
static let statusJoins = hasMany(TimelineStatusJoin.self) func save(_ db: Database) throws {
static let statuses = hasMany( try TimelineRecord(timeline: self).save(db)
StatusRecord.self, }
through: statusJoins,
using: TimelineStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc)
var statuses: QueryInterfaceRequest<StatusInfo> { init?(record: TimelineRecord) {
StatusInfo.request(request(for: Self.statuses)) 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 import MastodonAPI
public struct ProfileService { public struct ProfileService {
public let accountService: AnyPublisher<AccountService, Error> public let accountServicePublisher: AnyPublisher<AccountService, Error>
private let accountID: String private let accountID: String
private let mastodonAPIClient: MastodonAPIClient private let mastodonAPIClient: MastodonAPIClient
@ -45,18 +45,16 @@ public struct ProfileService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
accountService = accountPublisher accountServicePublisher = accountPublisher
.map { AccountService(account: $0, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } .map { AccountService(account: $0, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
public extension ProfileService { public extension ProfileService {
func statusListService( func statusListService(profileCollection: ProfileCollection) -> StatusListService {
collectionPublisher: CurrentValueSubject<ProfileCollection, Never>) -> StatusListService {
StatusListService( StatusListService(
accountID: accountID, timeline: .profile(accountId: accountID, profileCollection: profileCollection),
collection: collectionPublisher,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) contentDatabase: contentDatabase)
} }

View file

@ -21,15 +21,6 @@ public struct StatusListService {
extension StatusListService { extension StatusListService {
init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { 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? var title: String?
if case let .tag(tag) = timeline { if case let .tag(tag) = timeline {
@ -46,7 +37,7 @@ extension StatusListService {
status: nil, status: nil,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase), contentDatabase: contentDatabase),
filterContext: filterContext, filterContext: timeline.filterContext,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in contentDatabase: contentDatabase) { maxID, minID in
mastodonAPIClient.pagedRequest(timeline.endpoint, maxID: maxID, minID: minID) mastodonAPIClient.pagedRequest(timeline.endpoint, maxID: maxID, minID: minID)
@ -78,59 +69,6 @@ extension StatusListService {
.eraseToAnyPublisher() .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 { public extension StatusListService {
@ -142,3 +80,52 @@ public extension StatusListService {
contentDatabase.activeFiltersObservation(date: Date(), context: filterContext) 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 { switch timeline {
case .home, .list: case .home, .list:
return identification.identity.handle return identification.identity.handle
case .local, .federated, .tag: case .local, .federated, .tag, .profile:
return identification.identity.instance?.uri ?? "" return identification.identity.instance?.uri ?? ""
} }
} }

View file

@ -5,57 +5,86 @@ import Foundation
import Mastodon import Mastodon
import ServiceLayer import ServiceLayer
public class ProfileViewModel: StatusListViewModel { final public class ProfileViewModel {
@Published public private(set) var accountViewModel: AccountViewModel? @Published public private(set) var accountViewModel: AccountViewModel?
@Published public var collection = ProfileCollection.statuses @Published public var collection = ProfileCollection.statuses
@Published public var alertItem: AlertItem?
private let profileService: ProfileService private let profileService: ProfileService
private let collectionViewModel: CurrentValueSubject<StatusListViewModel, Never>
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(profileService: ProfileService) { init(profileService: ProfileService) {
self.profileService = profileService self.profileService = profileService
let collectionSubject = CurrentValueSubject<ProfileCollection, Never>(.statuses) collectionViewModel = CurrentValueSubject(
StatusListViewModel(statusListService: profileService.statusListService(profileCollection: .statuses)))
super.init( profileService.accountServicePublisher
statusListService: profileService.statusListService(
collectionPublisher: collectionSubject))
$collection.sink(receiveValue: collectionSubject.send).store(in: &cancellables)
profileService.accountService
.map(AccountViewModel.init(accountService:)) .map(AccountViewModel.init(accountService:))
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$accountViewModel) .assign(to: &$accountViewModel)
}
public override var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $collection.dropFirst()
// The pinned key is added to the info of collection items in the first section .map(profileService.statusListService(profileCollection:))
// so a diffable data source can potentially render it in both sections .map(StatusListViewModel.init(statusListService:))
super.collectionItems .sink { [weak self] in
.map { guard let self = self else { return }
$0.enumerated().map { [weak self] in
if let self = self, self.collection == .statuses, $0 == 0 { self.collectionViewModel.send($0)
return $1.map { .init(id: $0.id, kind: $0.kind, info: [.pinned: true]) } $0.$alertItem.assign(to: &self.$alertItem)
} else { }
return $1 .store(in: &cancellables)
} }
}
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 } $accountViewModel.compactMap { $0 }
.flatMap(\.events) .flatMap(\.events)
.flatMap { $0 } .flatMap { $0 }
.map(NavigationEvent.init) .map(NavigationEvent.init)
.compactMap { $0 } .compactMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.merge(with: super.navigationEvents) .merge(with: collectionViewModel.flatMap(\.navigationEvents))
.eraseToAnyPublisher() .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 { if case .statuses = collection, maxID == nil {
profileService.fetchPinnedStatuses() profileService.fetchPinnedStatuses()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
@ -63,10 +92,18 @@ public class ProfileViewModel: StatusListViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
super.request(maxID: maxID, minID: minID) collectionViewModel.value.request(maxID: maxID, minID: minID)
} }
public override var title: AnyPublisher<String?, Never> { public func itemSelected(_ item: CollectionItem) {
$accountViewModel.map { $0?.accountName }.eraseToAnyPublisher() 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 Mastodon
import ServiceLayer import ServiceLayer
public class StatusListViewModel: ObservableObject { final public class StatusListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]() @Published public private(set) var items = [[CollectionItem]]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public private(set) var nextPageMaxID: String? public private(set) var nextPageMaxID: String?
@ -40,13 +40,19 @@ public class StatusListViewModel: ObservableObject {
.sink { [weak self] in self?.nextPageMaxID = $0 } .sink { [weak self] in self?.nextPageMaxID = $0 }
.store(in: &cancellables) .store(in: &cancellables)
} }
}
extension StatusListViewModel: CollectionViewModel {
public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() } 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 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) { public func request(maxID: String? = nil, minID: String? = nil) {
statusListService.request(maxID: maxID, minID: minID) statusListService.request(maxID: maxID, minID: minID)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -57,12 +63,6 @@ public class StatusListViewModel: ObservableObject {
.sink { _ in } .sink { _ in }
.store(in: &cancellables) .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) { public func itemSelected(_ item: CollectionItem) {
switch item.kind { switch item.kind {

View file

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

View file

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