mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 08:10:59 +00:00
Account statuses wip
This commit is contained in:
parent
f2344fcbe9
commit
21f09b13e6
15 changed files with 336 additions and 72 deletions
12
DB/Sources/DB/Content/AccountPinnedStatusJoin.swift
Normal file
12
DB/Sources/DB/Content/AccountPinnedStatusJoin.swift
Normal 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")]))
|
||||
}
|
|
@ -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
|
||||
|
|
12
DB/Sources/DB/Content/AccountStatusJoin.swift
Normal file
12
DB/Sources/DB/Content/AccountStatusJoin.swift
Normal 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")]))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
9
DB/Sources/DB/Entities/AccountStatusCollection.swift
Normal file
9
DB/Sources/DB/Entities/AccountStatusCollection.swift
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AccountStatusCollection: String, Codable {
|
||||
case statuses
|
||||
case statusesAndReplies
|
||||
case media
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import Foundation
|
|||
import MastodonAPI
|
||||
import Stubbing
|
||||
|
||||
extension TimelinesEndpoint: Stubbing {
|
||||
extension StatusesEndpoint: Stubbing {
|
||||
public func data(url: URL) -> Data? {
|
||||
StubData.timeline
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import DB
|
||||
|
||||
public typealias AccountStatusCollection = DB.AccountStatusCollection
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
39
ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift
Normal file
39
ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue