Account statuses wip

This commit is contained in:
Justin Mazzocchi 2020-09-17 17:16:41 -07:00
parent f2344fcbe9
commit 21f09b13e6
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
15 changed files with 336 additions and 72 deletions

View file

@ -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")]))
}

View file

@ -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<StatusResult> {
request(for: Self.pinnedStatuses).statusResultRequest
}
init(account: Account) {
id = account.id

View file

@ -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")]))
}

View file

@ -82,6 +82,38 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func insert(pinnedStatuses: [Status], accountID: String) -> AnyPublisher<Never, Error> {
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<Never, Error> {
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<Never, Error> {
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)
}
}
}
}

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public enum AccountStatusCollection: String, Codable {
case statuses
case statusesAndReplies
case media
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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)
}
}
}

View file

@ -4,7 +4,7 @@ import Foundation
import MastodonAPI
import Stubbing
extension TimelinesEndpoint: Stubbing {
extension StatusesEndpoint: Stubbing {
public func data(url: URL) -> Data? {
StubData.timeline
}

View file

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

View file

@ -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<AccountStatusCollection, Never>) -> StatusListService {
StatusListService(
accountID: accountID,
collection: collectionPublisher,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
func fetchPinnedStatuses() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(
StatusesEndpoint.accountsStatuses(
id: accountID,
excludeReplies: true,
onlyMedia: false,
pinned: true))
.flatMap { contentDatabase.insert(pinnedStatuses: $0, accountID: accountID) }
.eraseToAnyPublisher()
}
}

View file

@ -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()

View file

@ -47,6 +47,51 @@ extension StatusListService {
.eraseToAnyPublisher()
}
}
init(
accountID: String,
collection: AnyPublisher<AccountStatusCollection, Never>,
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<Never, Error> 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,

View file

@ -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<AnyCancellable>()
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
}
}

View file

@ -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(