diff --git a/DB/Sources/DB/Content/AccountRecord.swift b/DB/Sources/DB/Content/AccountRecord.swift index 0693838..d82e761 100644 --- a/DB/Sources/DB/Content/AccountRecord.swift +++ b/DB/Sources/DB/Content/AccountRecord.swift @@ -70,21 +70,11 @@ extension AccountRecord { StatusRecord.self, through: pinnedStatusJoins, using: AccountPinnedStatusJoin.status) - static let statusJoins = hasMany(AccountStatusJoin.self) var pinnedStatuses: QueryInterfaceRequest { StatusInfo.request(request(for: Self.pinnedStatuses)) } - func statuses(collection: ProfileCollection) -> QueryInterfaceRequest { - 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 diff --git a/DB/Sources/DB/Content/AccountStatusJoin.swift b/DB/Sources/DB/Content/AccountStatusJoin.swift deleted file mode 100644 index 5e5acb7..0000000 --- a/DB/Sources/DB/Content/AccountStatusJoin.swift +++ /dev/null @@ -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) - } -} diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index d845e73..7e8a805 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -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) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 4b49f7c..17c9a38 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -99,21 +99,6 @@ public extension ContentDatabase { .eraseToAnyPublisher() } - func insert( - statuses: [Status], - accountID: String, - collection: ProfileCollection) -> AnyPublisher { - 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 { 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 { - databaseWriter.writePublisher(updates: Timeline.filter(Timeline.Columns.id == id).deleteAll) + databaseWriter.writePublisher(updates: TimelineRecord.filter(TimelineRecord.Columns.listId == id).deleteAll) .ignoreOutput() .eraseToAnyPublisher() } @@ -181,11 +166,22 @@ public extension ContentDatabase { } func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> { - ValueObservation.tracking(timeline.statuses.fetchAll) - .removeDuplicates() - .publisher(in: databaseWriter) - .map { [$0.map(Status.init(info:))] } - .eraseToAnyPublisher() + 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) + .eraseToAnyPublisher() } func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> { @@ -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 { 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) diff --git a/DB/Sources/DB/Content/TimelineRecord.swift b/DB/Sources/DB/Content/TimelineRecord.swift new file mode 100644 index 0000000..3206f51 --- /dev/null +++ b/DB/Sources/DB/Content/TimelineRecord.swift @@ -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.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 + } + } +} diff --git a/Mastodon/Sources/Mastodon/Entities/Timeline.swift b/DB/Sources/DB/Entities/Timeline.swift similarity index 69% rename from Mastodon/Sources/Mastodon/Entities/Timeline.swift rename to DB/Sources/DB/Entities/Timeline.swift index 7984e09..70839e6 100644 --- a/Mastodon/Sources/Mastodon/Entities/Timeline.swift +++ b/DB/Sources/DB/Entities/Timeline.swift @@ -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)" } } } diff --git a/DB/Sources/DB/Extensions/Identity+Internal.swift b/DB/Sources/DB/Extensions/Identity+Extensions.swift similarity index 100% rename from DB/Sources/DB/Extensions/Identity+Internal.swift rename to DB/Sources/DB/Extensions/Identity+Extensions.swift diff --git a/DB/Sources/DB/Extensions/Timeline+Extensions.swift b/DB/Sources/DB/Extensions/Timeline+Extensions.swift index 457685d..20d245b 100644 --- a/DB/Sources/DB/Extensions/Timeline+Extensions.swift +++ b/DB/Sources/DB/Extensions/Timeline+Extensions.swift @@ -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.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 + } } } diff --git a/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift b/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift deleted file mode 100644 index f34079f..0000000 --- a/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift +++ /dev/null @@ -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) - } - } -} diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift new file mode 100644 index 0000000..8bf8f0b --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import DB + +public typealias Timeline = DB.Timeline diff --git a/ServiceLayer/Sources/ServiceLayer/Services/ProfileService.swift b/ServiceLayer/Sources/ServiceLayer/Services/ProfileService.swift index f0ffc16..3352bf7 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/ProfileService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/ProfileService.swift @@ -7,7 +7,7 @@ import Mastodon import MastodonAPI public struct ProfileService { - public let accountService: AnyPublisher + public let accountServicePublisher: AnyPublisher 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) -> StatusListService { + func statusListService(profileCollection: ProfileCollection) -> StatusListService { StatusListService( - accountID: accountID, - collection: collectionPublisher, + timeline: .profile(accountId: accountID, profileCollection: profileCollection), mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift index 50da057..e59f8a5 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift @@ -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, - mastodonAPIClient: MastodonAPIClient, - contentDatabase: ContentDatabase) { - let nextPageMaxIDsSubject = PassthroughSubject() - - 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 + } + } +} diff --git a/ViewModels/Sources/ViewModels/Entities/Timeline.swift b/ViewModels/Sources/ViewModels/Entities/Timeline.swift new file mode 100644 index 0000000..a56f456 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/Timeline.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ServiceLayer + +public typealias Timeline = ServiceLayer.Timeline diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 87fa464..ce354d8 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -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 ?? "" } } diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 8b54c9c..b712c89 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -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 private var cancellables = Set() init(profileService: ProfileService) { self.profileService = profileService - let collectionSubject = CurrentValueSubject(.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) - } - 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 { - $0.enumerated().map { [weak self] in - if let self = self, self.collection == .statuses, $0 == 0 { - return $1.map { .init(id: $0.id, kind: $0.kind, info: [.pinned: true]) } - } else { - return $1 - } + $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) + } +} + +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 { + public var title: AnyPublisher { + $accountViewModel.map { $0?.accountName }.eraseToAnyPublisher() + } + + public var alertItems: AnyPublisher { + collectionViewModel.flatMap(\.alertItems).eraseToAnyPublisher() + } + + public var loading: AnyPublisher { + collectionViewModel.flatMap(\.loading).eraseToAnyPublisher() + } + + public var navigationEvents: AnyPublisher { $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 { - $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) } } diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index 4bb1e93..550eb92 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -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 { navigationEventsSubject.eraseToAnyPublisher() } - public var title: AnyPublisher { Just(statusListService.title).eraseToAnyPublisher() } + public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } + + public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } + + public var navigationEvents: AnyPublisher { 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.compactMap { $0 }.eraseToAnyPublisher() } - - public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } public func itemSelected(_ item: CollectionItem) { switch item.kind { diff --git a/Views/AccountHeaderView.swift b/Views/AccountHeaderView.swift index 837b89f..a6f3419 100644 --- a/Views/AccountHeaderView.swift +++ b/Views/AccountHeaderView.swift @@ -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) diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 25434a0..9af6c07 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -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" } } }