Account relationship and identity proofs

This commit is contained in:
Justin Mazzocchi 2020-11-10 23:31:56 -08:00
parent b302388a9b
commit ad4b238883
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
19 changed files with 359 additions and 21 deletions

View file

@ -54,6 +54,8 @@ extension AccountRecord {
extension AccountRecord { extension AccountRecord {
static let moved = belongsTo(AccountRecord.self) static let moved = belongsTo(AccountRecord.self)
static let relationship = hasOne(Relationship.self)
static let identityProofs = hasMany(IdentityProofRecord.self)
static let pinnedStatusJoins = hasMany(AccountPinnedStatusJoin.self) static let pinnedStatusJoins = hasMany(AccountPinnedStatusJoin.self)
.order(AccountPinnedStatusJoin.Columns.index) .order(AccountPinnedStatusJoin.Columns.index)
static let pinnedStatuses = hasMany( static let pinnedStatuses = hasMany(

View file

@ -30,6 +30,33 @@ extension ContentDatabase {
t.column("movedId", .text).references("accountRecord") t.column("movedId", .text).references("accountRecord")
} }
try db.create(table: "relationship") { t in
t.column("id", .text).primaryKey(onConflict: .replace)
.references("accountRecord", onDelete: .cascade)
t.column("following", .boolean).notNull()
t.column("requested", .boolean).notNull()
t.column("endorsed", .boolean).notNull()
t.column("followedBy", .boolean).notNull()
t.column("muting", .boolean).notNull()
t.column("mutingNotifications", .boolean).notNull()
t.column("showingReblogs", .boolean).notNull()
t.column("blocking", .boolean).notNull()
t.column("domainBlocking", .boolean).notNull()
t.column("blockedBy", .boolean).notNull()
t.column("note", .text).notNull()
}
try db.create(table: "identityProofRecord") { t in
t.column("accountId", .text).notNull().references("accountRecord", onDelete: .cascade)
t.column("provider", .text).notNull()
t.column("providerUsername", .text).notNull()
t.column("profileUrl", .text).notNull()
t.column("proofUrl", .text).notNull()
t.column("updatedAt", .date).notNull()
t.primaryKey(["accountId", "provider"], onConflict: .replace)
}
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()

View file

@ -233,6 +233,39 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func insert(account: Account) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher(updates: account.save)
.ignoreOutput()
.eraseToAnyPublisher()
}
func insert(identityProofs: [IdentityProof], id: Account.Id) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
for identityProof in identityProofs {
try IdentityProofRecord(
accountId: id,
provider: identityProof.provider,
providerUsername: identityProof.providerUsername,
profileUrl: identityProof.profileUrl,
proofUrl: identityProof.proofUrl,
updatedAt: identityProof.updatedAt)
.save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func insert(relationships: [Relationship]) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
for relationship in relationships {
try relationship.save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func setLists(_ lists: [List]) -> AnyPublisher<Never, Error> { func setLists(_ lists: [List]) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher { databaseWriter.writePublisher {
for list in lists { for list in lists {
@ -347,12 +380,20 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func accountPublisher(id: Account.Id) -> AnyPublisher<Account, Error> { func profilePublisher(id: Account.Id) -> AnyPublisher<Profile, Error> {
ValueObservation.tracking(AccountInfo.request(AccountRecord.filter(AccountRecord.Columns.id == id)).fetchOne) ValueObservation.tracking(ProfileInfo.request(AccountRecord.filter(AccountRecord.Columns.id == id)).fetchOne)
.removeDuplicates()
.publisher(in: databaseWriter)
.compactMap { $0 }
.map(Profile.init(info:))
.eraseToAnyPublisher()
}
func relationshipPublisher(id: Account.Id) -> AnyPublisher<Relationship, Error> {
ValueObservation.tracking(Relationship.filter(Relationship.Columns.id == id).fetchOne)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.compactMap { $0 } .compactMap { $0 }
.map(Account.init(info:))
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -0,0 +1,25 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
struct IdentityProofRecord: ContentDatabaseRecord, Hashable {
let accountId: Account.Id
let provider: String
let providerUsername: String
let profileUrl: URL
let proofUrl: URL
let updatedAt: Date
}
extension IdentityProofRecord {
enum Columns {
static let accountId = Column(IdentityProofRecord.CodingKeys.accountId)
static let provider = Column(IdentityProofRecord.CodingKeys.provider)
static let providerUsername = Column(IdentityProofRecord.CodingKeys.providerUsername)
static let profileUrl = Column(IdentityProofRecord.CodingKeys.profileUrl)
static let proofUrl = Column(IdentityProofRecord.CodingKeys.proofUrl)
static let updatedAt = Column(IdentityProofRecord.CodingKeys.updatedAt)
}
}

View file

@ -0,0 +1,23 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
struct ProfileInfo: Codable, Hashable, FetchableRecord {
let accountInfo: AccountInfo
let relationship: Relationship?
let identityProofRecords: [IdentityProofRecord]
}
extension ProfileInfo {
static func addingIncludes<T: DerivableRequest>(_ request: T) -> T where T.RowDecoder == AccountRecord {
AccountInfo.addingIncludes(request)
.including(optional: AccountRecord.relationship.forKey(CodingKeys.relationship))
.including(all: AccountRecord.identityProofs.forKey(CodingKeys.identityProofRecords))
}
static func request(_ request: QueryInterfaceRequest<AccountRecord>) -> QueryInterfaceRequest<Self> {
addingIncludes(request).asRequest(of: self)
}
}

View file

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Mastodon
public struct Profile: Codable, Hashable {
public let account: Account
public let relationship: Relationship?
public let identityProofs: [IdentityProof]
public init(account: Account) {
self.account = account
self.relationship = nil
self.identityProofs = []
}
}
extension Profile {
init(info: ProfileInfo) {
account = Account(info: info.accountInfo)
relationship = info.relationship
identityProofs = info.identityProofRecords.map(IdentityProof.init(record:))
}
}

View file

@ -0,0 +1,16 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
extension IdentityProof {
init(record: IdentityProofRecord) {
self.init(
provider: record.provider,
providerUsername: record.providerUsername,
profileUrl: record.profileUrl,
proofUrl: record.proofUrl,
updatedAt: record.updatedAt)
}
}

View file

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
extension Relationship: ContentDatabaseRecord {}
extension Relationship {
enum Columns: String, ColumnExpression {
case id
case following
case requested
case endorsed
case followedBy
case muting
case mutingNotifications
case showingReblogs
case blocking
case domainBlocking
case blockedBy
case note
}
}

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public struct IdentityProof: Codable, Hashable {
public let provider: String
public let providerUsername: String
public let profileUrl: URL
public let proofUrl: URL
public let updatedAt: Date
public init(provider: String, providerUsername: String, profileUrl: URL, proofUrl: URL, updatedAt: Date) {
self.provider = provider
self.providerUsername = providerUsername
self.profileUrl = profileUrl
self.proofUrl = proofUrl
self.updatedAt = updatedAt
}
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public struct Relationship: Codable, Hashable {
public let id: Account.Id
public let following: Bool
public let requested: Bool
@DecodableDefault.False public private(set) var endorsed: Bool
public let followedBy: Bool
public let muting: Bool
@DecodableDefault.False public private(set) var mutingNotifications: Bool
@DecodableDefault.False public private(set) var showingReblogs: Bool
public let blocking: Bool
public let domainBlocking: Bool
@DecodableDefault.False public private(set) var blockedBy: Bool
@DecodableDefault.EmptyString public private(set) var note: String
}

View file

@ -0,0 +1,31 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum IdentityProofsEndpoint {
case identityProofs(id: Account.Id)
}
extension IdentityProofsEndpoint: Endpoint {
public typealias ResultType = [IdentityProof]
public var context: [String] {
defaultContext + ["accounts"]
}
public var pathComponentsInContext: [String] {
switch self {
case let .identityProofs(id):
return [id, "identity_proofs"]
}
}
public var method: HTTPMethod {
switch self {
case .identityProofs:
return .get
}
}
}

View file

@ -0,0 +1,31 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum RelationshipsEndpoint {
case relationships(ids: [Account.Id])
}
extension RelationshipsEndpoint: Endpoint {
public typealias ResultType = [Relationship]
public var pathComponentsInContext: [String] {
["accounts", "relationships"]
}
public var queryParameters: [URLQueryItem] {
switch self {
case let .relationships(ids):
return ids.map { URLQueryItem(name: "id[]", value: $0) }
}
}
public var method: HTTPMethod {
switch self {
case .relationships:
return .get
}
}
}

View file

@ -8,13 +8,21 @@ import MastodonAPI
public struct AccountService { public struct AccountService {
public let account: Account public let account: Account
public let relationship: Relationship?
public let identityProofs: [IdentityProof]
public let navigationService: NavigationService public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase private let contentDatabase: ContentDatabase
init(account: Account, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { init(account: Account,
relationship: Relationship? = nil,
identityProofs: [IdentityProof] = [],
mastodonAPIClient: MastodonAPIClient,
contentDatabase: ContentDatabase) {
self.account = account self.account = account
self.relationship = relationship
self.identityProofs = identityProofs
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
self.mastodonAPIClient = mastodonAPIClient self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase

View file

@ -34,17 +34,24 @@ public struct ProfileService {
self.mastodonAPIClient = mastodonAPIClient self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase
var accountPublisher = contentDatabase.accountPublisher(id: id) var accountPublisher = contentDatabase.profilePublisher(id: id)
if let account = account { if let account = account {
accountPublisher = accountPublisher accountPublisher = accountPublisher
.merge(with: Just(account).setFailureType(to: Error.self)) .merge(with: Just(Profile(account: account)).setFailureType(to: Error.self))
.removeDuplicates() .removeDuplicates()
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
accountServicePublisher = accountPublisher accountServicePublisher = accountPublisher
.map { AccountService(account: $0, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } .map {
AccountService(
account: $0.account,
relationship: $0.relationship,
identityProofs: $0.identityProofs,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
@ -57,6 +64,18 @@ public extension ProfileService {
contentDatabase: contentDatabase) contentDatabase: contentDatabase)
} }
func fetchProfile() -> AnyPublisher<Never, Error> {
Publishers.Merge3(
mastodonAPIClient.request(AccountEndpoint.accounts(id: id))
.flatMap { contentDatabase.insert(account: $0) },
mastodonAPIClient.request(RelationshipsEndpoint.relationships(ids: [id]))
.flatMap { contentDatabase.insert(relationships: $0) },
mastodonAPIClient.request(IdentityProofsEndpoint.identityProofs(id: id))
.catch { _ in Empty() }
.flatMap { contentDatabase.insert(identityProofs: $0, id: id) })
.eraseToAnyPublisher()
}
func fetchPinnedStatuses() -> AnyPublisher<Never, Error> { func fetchPinnedStatuses() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request( mastodonAPIClient.request(
StatusesEndpoint.accountsStatuses( StatusesEndpoint.accountsStatuses(

View file

@ -45,4 +45,12 @@ final class ProfileViewController: TableViewController {
tableView.tableHeaderView = accountHeaderView tableView.tableHeaderView = accountHeaderView
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.fetchProfile()
.sink { _ in }
.store(in: &cancellables)
}
} }

View file

@ -36,6 +36,8 @@ public extension AccountViewModel {
var isLocked: Bool { accountService.account.locked } var isLocked: Bool { accountService.account.locked }
var identityProofs: [IdentityProof] { accountService.identityProofs }
var fields: [Account.Field] { accountService.account.fields } var fields: [Account.Field] { accountService.account.fields }
var note: NSAttributedString { accountService.account.note.attributed } var note: NSAttributedString { accountService.account.note.attributed }

View file

@ -55,6 +55,10 @@ public extension ProfileViewModel {
imagePresentationsSubject.send(accountViewModel.avatarURL(profile: true)) imagePresentationsSubject.send(accountViewModel.avatarURL(profile: true))
} }
func fetchProfile() -> AnyPublisher<Never, Never> {
profileService.fetchProfile().assignErrorsToAlertItem(to: \.alertItem, on: self)
}
} }
extension ProfileViewModel: CollectionViewModel { extension ProfileViewModel: CollectionViewModel {

View file

@ -8,11 +8,9 @@ final class AccountFieldView: UIView {
let valueTextView = TouchFallthroughTextView() let valueTextView = TouchFallthroughTextView()
// swiftlint:disable:next function_body_length // swiftlint:disable:next function_body_length
init(field: Account.Field, emoji: [Emoji]) { init(name: String, value: NSAttributedString, verifiedAt: Date?, emoji: [Emoji]) {
super.init(frame: .zero) super.init(frame: .zero)
let verified = field.verifiedAt != nil
backgroundColor = .systemBackground backgroundColor = .systemBackground
let nameBackgroundView = UIView() let nameBackgroundView = UIView()
@ -25,9 +23,9 @@ final class AccountFieldView: UIView {
addSubview(valueBackgroundView) addSubview(valueBackgroundView)
valueBackgroundView.translatesAutoresizingMaskIntoConstraints = false valueBackgroundView.translatesAutoresizingMaskIntoConstraints = false
valueBackgroundView.backgroundColor = verified valueBackgroundView.backgroundColor = verifiedAt == nil
? UIColor.systemGreen.withAlphaComponent(0.25) ? .systemBackground
: .systemBackground : UIColor.systemGreen.withAlphaComponent(0.25)
addSubview(nameLabel) addSubview(nameLabel)
nameLabel.translatesAutoresizingMaskIntoConstraints = false nameLabel.translatesAutoresizingMaskIntoConstraints = false
@ -36,7 +34,7 @@ final class AccountFieldView: UIView {
nameLabel.textAlignment = .center nameLabel.textAlignment = .center
nameLabel.textColor = .secondaryLabel nameLabel.textColor = .secondaryLabel
let mutableName = NSMutableAttributedString(string: field.name) let mutableName = NSMutableAttributedString(string: name)
mutableName.insert(emoji: emoji, view: nameLabel) mutableName.insert(emoji: emoji, view: nameLabel)
mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight) mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight)
@ -53,14 +51,14 @@ final class AccountFieldView: UIView {
valueTextView.isScrollEnabled = false valueTextView.isScrollEnabled = false
valueTextView.backgroundColor = .clear valueTextView.backgroundColor = .clear
if verified { if verifiedAt != nil {
valueTextView.linkTextAttributes = [ valueTextView.linkTextAttributes = [
.foregroundColor: UIColor.systemGreen as Any, .foregroundColor: UIColor.systemGreen as Any,
.underlineColor: UIColor.clear] .underlineColor: UIColor.clear]
} }
let valueFont = UIFont.preferredFont(forTextStyle: verified ? .headline : .body) let valueFont = UIFont.preferredFont(forTextStyle: verifiedAt == nil ? .body : .headline)
let mutableValue = NSMutableAttributedString(attributedString: field.value.attributed) let mutableValue = NSMutableAttributedString(attributedString: value)
let valueRange = NSRange(location: 0, length: mutableValue.length) let valueRange = NSRange(location: 0, length: mutableValue.length)
mutableValue.removeAttribute(.font, range: valueRange) mutableValue.removeAttribute(.font, range: valueRange)
@ -85,10 +83,10 @@ final class AccountFieldView: UIView {
addSubview(checkButton) addSubview(checkButton)
checkButton.translatesAutoresizingMaskIntoConstraints = false checkButton.translatesAutoresizingMaskIntoConstraints = false
checkButton.tintColor = .systemGreen checkButton.tintColor = .systemGreen
checkButton.isHidden = !verified checkButton.isHidden = verifiedAt == nil
checkButton.showsMenuAsPrimaryAction = true checkButton.showsMenuAsPrimaryAction = true
if let verifiedAt = field.verifiedAt { if let verifiedAt = verifiedAt {
checkButton.menu = UIMenu( checkButton.menu = UIMenu(
title: String.localizedStringWithFormat( title: String.localizedStringWithFormat(
NSLocalizedString("account.field.verified", comment: ""), NSLocalizedString("account.field.verified", comment: ""),
@ -118,7 +116,7 @@ final class AccountFieldView: UIView {
dividerView.widthAnchor.constraint(equalToConstant: .hairline), dividerView.widthAnchor.constraint(equalToConstant: .hairline),
checkButton.leadingAnchor.constraint(equalTo: dividerView.trailingAnchor, constant: .defaultSpacing), checkButton.leadingAnchor.constraint(equalTo: dividerView.trailingAnchor, constant: .defaultSpacing),
valueTextView.leadingAnchor.constraint( valueTextView.leadingAnchor.constraint(
equalTo: verified ? checkButton.trailingAnchor : dividerView.trailingAnchor, equalTo: verifiedAt == nil ? dividerView.trailingAnchor : checkButton.trailingAnchor,
constant: .defaultSpacing), constant: .defaultSpacing),
valueTextView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: .defaultSpacing), valueTextView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: .defaultSpacing),
valueTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -.defaultSpacing), valueTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -.defaultSpacing),

View file

@ -43,8 +43,26 @@ final class AccountHeaderView: UIView {
view.removeFromSuperview() view.removeFromSuperview()
} }
for identityProof in accountViewModel.identityProofs {
let fieldView = AccountFieldView(
name: identityProof.provider,
value: NSAttributedString(
string: identityProof.providerUsername,
attributes: [.link: identityProof.profileUrl]),
verifiedAt: identityProof.updatedAt,
emoji: [])
fieldView.valueTextView.delegate = self
fieldsStackView.addArrangedSubview(fieldView)
}
for field in accountViewModel.fields { for field in accountViewModel.fields {
let fieldView = AccountFieldView(field: field, emoji: accountViewModel.emoji) let fieldView = AccountFieldView(
name: field.name,
value: field.value.attributed,
verifiedAt: field.verifiedAt,
emoji: accountViewModel.emoji)
fieldView.valueTextView.delegate = self fieldView.valueTextView.delegate = self