diff --git a/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift b/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift new file mode 100644 index 0000000..0a6c1ff --- /dev/null +++ b/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct AccountPinnedStatusJoin: Codable, FetchableRecord, PersistableRecord { + let accountId: String + let statusId: String + let index: Int + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Column("statusId")])) +} diff --git a/DB/Sources/DB/Content/AccountRecord.swift b/DB/Sources/DB/Content/AccountRecord.swift index 96069f2..25060df 100644 --- a/DB/Sources/DB/Content/AccountRecord.swift +++ b/DB/Sources/DB/Content/AccountRecord.swift @@ -39,6 +39,18 @@ extension AccountRecord: FetchableRecord, PersistableRecord { extension AccountRecord { static let moved = belongsTo(AccountRecord.self, key: "moved") + static let pinnedStatusJoins = hasMany( + AccountPinnedStatusJoin.self, + using: ForeignKey([Column("accountId")])) + .order(Column("index")) + static let pinnedStatuses = hasMany( + StatusRecord.self, + through: pinnedStatusJoins, + using: AccountPinnedStatusJoin.status) + + var pinnedStatuses: QueryInterfaceRequest { + request(for: Self.pinnedStatuses).statusResultRequest + } init(account: Account) { id = account.id diff --git a/DB/Sources/DB/Content/AccountStatusJoin.swift b/DB/Sources/DB/Content/AccountStatusJoin.swift new file mode 100644 index 0000000..fe36c61 --- /dev/null +++ b/DB/Sources/DB/Content/AccountStatusJoin.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct AccountStatusJoin: Codable, FetchableRecord, PersistableRecord { + let accountId: String + let statusId: String + let collection: AccountStatusCollection + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Column("statusId")])) +} diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 2c0fd5d..60bacf7 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -82,6 +82,38 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func insert(pinnedStatuses: [Status], accountID: String) -> AnyPublisher { + databaseQueue.writePublisher { + for (index, status) in pinnedStatuses.enumerated() { + try status.save($0) + + try AccountPinnedStatusJoin(accountId: accountID, statusId: status.id, index: index).save($0) + } + + try AccountPinnedStatusJoin.filter( + Column("accountId") == accountID + && !pinnedStatuses.map(\.id).contains(Column("statusId"))) + .deleteAll($0) + } + .ignoreOutput() + .eraseToAnyPublisher() + } + + func insert( + statuses: [Status], + accountID: String, + collection: AccountStatusCollection) -> AnyPublisher { + databaseQueue.writePublisher { + for status in statuses { + try status.save($0) + + try AccountStatusJoin(accountId: accountID, statusId: status.id, collection: collection).save($0) + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + func setLists(_ lists: [MastodonList]) -> AnyPublisher { databaseQueue.writePublisher { for list in lists { @@ -158,6 +190,35 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func statusesObservation( + accountID: String, + collection: AccountStatusCollection) -> AnyPublisher<[[Status]], Error> { + ValueObservation.tracking { db -> [[StatusResult]] in + let statuses = try StatusRecord.filter( + AccountStatusJoin + .select(Column("statusId"), as: String.self) + .filter(sql: "accountId = ? AND collection = ?", arguments: [accountID, collection.rawValue]) + .contains(Column("id"))) + .order(Column("createdAt").desc) + .statusResultRequest + .fetchAll(db) + + if + case .statuses = collection, + let accountRecord = try AccountRecord.filter(Column("id") == accountID).fetchOne(db) { + let pinnedStatuses = try accountRecord.pinnedStatuses.fetchAll(db) + + return [pinnedStatuses, statuses] + } else { + return [statuses] + } + } + .removeDuplicates() + .publisher(in: databaseQueue) + .map { $0.map { $0.map(Status.init(result:)) } } + .eraseToAnyPublisher() + } + func listsObservation() -> AnyPublisher<[Timeline], Error> { ValueObservation.tracking(Timeline.filter(Column("listTitle") != nil) .order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc) @@ -287,17 +348,29 @@ private extension ContentDatabase { private static func createTemporaryTables(_ writer: DatabaseWriter) throws { try writer.write { db in try db.create(table: "statusContextJoin", temporary: true) { t in - t.column("parentId", .text) - .indexed() - .notNull() - t.column("statusId", .text) - .indexed() - .notNull() + t.column("parentId", .text).indexed().notNull() + t.column("statusId", .text).indexed().notNull() t.column("section", .text).notNull() t.column("index", .integer).notNull() t.primaryKey(["parentId", "statusId"], onConflict: .replace) } + + try db.create(table: "accountPinnedStatusJoin", temporary: true) { t in + t.column("accountId", .text).indexed().notNull() + t.column("statusId", .text).indexed().notNull() + t.column("index", .integer).notNull() + + t.primaryKey(["accountId", "statusId"], onConflict: .replace) + } + + try db.create(table: "accountStatusJoin", temporary: true) { t in + t.column("accountId", .text).indexed().notNull() + t.column("statusId", .text).indexed().notNull() + t.column("collection", .text).notNull() + + t.primaryKey(["accountId", "statusId", "collection"], onConflict: .replace) + } } } } diff --git a/DB/Sources/DB/Entities/AccountStatusCollection.swift b/DB/Sources/DB/Entities/AccountStatusCollection.swift new file mode 100644 index 0000000..6ca63e9 --- /dev/null +++ b/DB/Sources/DB/Entities/AccountStatusCollection.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public enum AccountStatusCollection: String, Codable { + case statuses + case statusesAndReplies + case media +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift new file mode 100644 index 0000000..3b140d9 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift @@ -0,0 +1,54 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum StatusesEndpoint { + case timelinesPublic(local: Bool) + case timelinesTag(String) + case timelinesHome + case timelinesList(id: String) + case accountsStatuses(id: String, excludeReplies: Bool, onlyMedia: Bool, pinned: Bool) +} + +extension StatusesEndpoint: Endpoint { + public typealias ResultType = [Status] + + public var context: [String] { + switch self { + case .timelinesPublic, .timelinesTag, .timelinesHome, .timelinesList: + return defaultContext + ["timelines"] + case .accountsStatuses: + return defaultContext + ["accounts"] + } + } + + public var pathComponentsInContext: [String] { + switch self { + case .timelinesPublic: + return ["public"] + case let .timelinesTag(tag): + return ["tag", tag] + case .timelinesHome: + return ["home"] + case let .timelinesList(id): + return ["list", id] + case let .accountsStatuses(id, _, _, _): + return [id, "statuses"] + } + } + + public var parameters: [String: Any]? { + switch self { + case let .timelinesPublic(local): + return ["local": local] + case let .accountsStatuses(_, excludeReplies, onlyMedia, pinned): + return ["exclude_replies": excludeReplies, "only_media": onlyMedia, "pinned": pinned] + default: + return nil + } + } + + public var method: HTTPMethod { .get } +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/TimelinesEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/TimelinesEndpoint.swift deleted file mode 100644 index f61079c..0000000 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/TimelinesEndpoint.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Foundation -import HTTP -import Mastodon - -public enum TimelinesEndpoint { - case `public`(local: Bool) - case tag(String) - case home - case list(id: String) -} - -extension TimelinesEndpoint: Endpoint { - public typealias ResultType = [Status] - - public var context: [String] { - defaultContext + ["timelines"] - } - - public var pathComponentsInContext: [String] { - switch self { - case .public: - return ["public"] - case let .tag(tag): - return ["tag", tag] - case .home: - return ["home"] - case let .list(id): - return ["list", id] - } - } - - public var parameters: [String: Any]? { - switch self { - case let .public(local): - return ["local": local] - default: - return nil - } - } - - public var method: HTTPMethod { .get } -} diff --git a/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift b/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift index e957043..f34079f 100644 --- a/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift +++ b/MastodonAPI/Sources/MastodonAPI/Extensions/Timeline+Extensions.swift @@ -4,18 +4,18 @@ import Foundation import Mastodon public extension Timeline { - var endpoint: TimelinesEndpoint { + var endpoint: StatusesEndpoint { switch self { case .home: - return .home + return .timelinesHome case .local: - return .public(local: true) + return .timelinesPublic(local: true) case .federated: - return .public(local: false) + return .timelinesPublic(local: false) case let .list(list): - return .list(id: list.id) + return .timelinesList(id: list.id) case let .tag(tag): - return .tag(tag) + return .timelinesTag(tag) } } } diff --git a/MastodonAPI/Sources/MastodonAPIStubs/TimelinesEndpoint+Stubbing.swift b/MastodonAPI/Sources/MastodonAPIStubs/TimelinesEndpoint+Stubbing.swift index b82ac30..4ca136f 100644 --- a/MastodonAPI/Sources/MastodonAPIStubs/TimelinesEndpoint+Stubbing.swift +++ b/MastodonAPI/Sources/MastodonAPIStubs/TimelinesEndpoint+Stubbing.swift @@ -4,7 +4,7 @@ import Foundation import MastodonAPI import Stubbing -extension TimelinesEndpoint: Stubbing { +extension StatusesEndpoint: Stubbing { public func data(url: URL) -> Data? { StubData.timeline } diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/AccountStatusCollection.swift b/ServiceLayer/Sources/ServiceLayer/Entities/AccountStatusCollection.swift new file mode 100644 index 0000000..782147e --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/AccountStatusCollection.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import DB + +public typealias AccountStatusCollection = DB.AccountStatusCollection diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountStatusesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountStatusesService.swift new file mode 100644 index 0000000..14dbbe9 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountStatusesService.swift @@ -0,0 +1,40 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import Mastodon +import MastodonAPI + +public struct AccountStatusesService { + private let accountID: String + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + + init(id: String, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + accountID = id + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + } +} + +public extension AccountStatusesService { + func statusListService(collectionPublisher: AnyPublisher) -> StatusListService { + StatusListService( + accountID: accountID, + collection: collectionPublisher, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + } + + func fetchPinnedStatuses() -> AnyPublisher { + mastodonAPIClient.request( + StatusesEndpoint.accountsStatuses( + id: accountID, + excludeReplies: true, + onlyMedia: false, + pinned: true)) + .flatMap { contentDatabase.insert(pinnedStatuses: $0, accountID: accountID) } + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift index 3297341..27b7cac 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift @@ -53,7 +53,7 @@ public extension InstanceURLService { httpClient.request( MastodonAPITarget( baseURL: url, - endpoint: TimelinesEndpoint.public(local: true), + endpoint: StatusesEndpoint.timelinesPublic(local: true), accessToken: nil)) .map { _ in true } .eraseToAnyPublisher() diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift index fa7991e..45537af 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift @@ -47,6 +47,51 @@ extension StatusListService { .eraseToAnyPublisher() } } + + init( + accountID: String, + collection: AnyPublisher, + mastodonAPIClient: MastodonAPIClient, + contentDatabase: ContentDatabase) { + self.init( + statusSections: collection + .flatMap { contentDatabase.statusesObservation(accountID: accountID, collection: $0) } + .eraseToAnyPublisher(), + paginates: true, + contextParentID: nil, + title: "turn this into a closure or publisher", + filterContext: .account, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) { maxID, minID in + Just((maxID, minID)).combineLatest(collection).flatMap { params -> AnyPublisher in + let ((maxID, minID), collection) = params + let excludeReplies: Bool + let onlyMedia: Bool + + switch collection { + 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.request(Paged(endpoint, maxID: maxID, minID: minID)) + .flatMap { contentDatabase.insert(statuses: $0, accountID: accountID, collection: collection) } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } } public extension StatusListService { @@ -66,6 +111,10 @@ public extension StatusListService { Self(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + func service(accountID: String) -> AccountStatusesService { + AccountStatusesService(id: accountID, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } + func contextService(statusID: String) -> Self { Self(statusSections: contentDatabase.contextObservation(parentID: statusID), paginates: false, diff --git a/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift b/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift new file mode 100644 index 0000000..0da1619 --- /dev/null +++ b/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift @@ -0,0 +1,39 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public class AccountStatusesViewModel: StatusListViewModel { + @Published var collection: AccountStatusCollection + private let accountStatusesService: AccountStatusesService + private var cancellables = Set() + + init(accountStatusesService: AccountStatusesService) { + self.accountStatusesService = accountStatusesService + + var collection = Published(initialValue: AccountStatusCollection.statuses) + + _collection = collection + + super.init( + statusListService: accountStatusesService.statusListService( + collectionPublisher: collection.projectedValue.eraseToAnyPublisher())) + } + + public override func request(maxID: String? = nil, minID: String? = nil) { + if case .statuses = collection, maxID == nil { + accountStatusesService.fetchPinnedStatuses() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { _ in } + .store(in: &cancellables) + } + + super.request(maxID: maxID, minID: minID) + } + + override func isPinned(status: Status) -> Bool { + collection == .statuses && statusIDs.first?.contains(status.id) ?? false + } +} diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index 710f2c3..7f57c48 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -5,7 +5,7 @@ import Foundation import Mastodon import ServiceLayer -public final class StatusListViewModel: ObservableObject { +public class StatusListViewModel: ObservableObject { @Published public private(set) var statusIDs = [[String]]() @Published public var alertItem: AlertItem? @Published public private(set) var loading = false @@ -35,6 +35,19 @@ public final class StatusListViewModel: ObservableObject { .map { $0.map { $0.map(\.id) } } .assign(to: &$statusIDs) } + + public func request(maxID: String? = nil, minID: String? = nil) { + statusListService.request(maxID: maxID, minID: minID) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.loading = true }, + receiveCompletion: { [weak self] _ in self?.loading = false }) + .sink { _ in } + .store(in: &cancellables) + } + + func isPinned(status: Status) -> Bool { false } } public extension StatusListViewModel { @@ -52,17 +65,6 @@ public extension StatusListViewModel { var contextParentID: String? { statusListService.contextParentID } - func request(maxID: String? = nil, minID: String? = nil) { - statusListService.request(maxID: maxID, minID: minID) - .receive(on: DispatchQueue.main) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .handleEvents( - receiveSubscription: { [weak self] _ in self?.loading = true }, - receiveCompletion: { [weak self] _ in self?.loading = false }) - .sink { _ in } - .store(in: &cancellables) - } - func statusViewModel(id: String) -> StatusViewModel? { guard let status = statuses[id] else { return nil } @@ -85,7 +87,7 @@ public extension StatusListViewModel { } statusViewModel.isContextParent = status.id == statusListService.contextParentID - statusViewModel.isPinned = status.displayStatus.pinned ?? false + statusViewModel.isPinned = isPinned(status: status) statusViewModel.isReplyInContext = isReplyInContext(status: status) statusViewModel.hasReplyFollowing = hasReplyFollowing(status: status) @@ -117,7 +119,8 @@ private extension StatusListViewModel { case let .url(url): return .urlNavigation(url) case let .accountID(id): - return nil + return .statusListNavigation( + AccountStatusesViewModel(accountStatusesService: statusListService.service(accountID: id))) case let .statusID(id): return .statusListNavigation( StatusListViewModel(