Follow requests

This commit is contained in:
Justin Mazzocchi 2021-01-25 22:57:44 -08:00
parent 195f2d6a29
commit 1fabcb41cc
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
21 changed files with 243 additions and 63 deletions

View file

@ -524,7 +524,7 @@ public extension ContentDatabase {
accountIds.firstIndex(of: $0.record.id) ?? 0
< accountIds.firstIndex(of: $1.record.id) ?? 0
}
.map { CollectionItem.account(.init(info: $0)) }
.map { CollectionItem.account(.init(info: $0), .withoutNote) }
if let limit = limit, accounts.count >= limit {
accounts.append(.moreResults(.init(scope: .accounts)))

View file

@ -5,7 +5,7 @@ import Mastodon
public enum CollectionItem: Hashable {
case status(Status, StatusConfiguration)
case loadMore(LoadMore)
case account(Account)
case account(Account, AccountConfiguration)
case notification(MastodonNotification, StatusConfiguration?)
case conversation(Conversation)
case tag(Tag)
@ -38,13 +38,19 @@ public extension CollectionItem {
}
}
enum AccountConfiguration: Hashable {
case withNote
case withoutNote
case followRequest
}
var itemId: Id? {
switch self {
case let .status(status, _):
return status.id
case .loadMore:
return nil
case let .account(account):
case let .account(account, _):
return account.id
case let .notification(notification, _):
return notification.id

View file

@ -37,6 +37,7 @@ public extension Identity {
public let header: URL
public let headerStatic: URL
public let emojis: [Emoji]
public let followRequestCount: Int
}
struct Preferences: Codable, Hashable {

View file

@ -38,6 +38,7 @@ extension IdentityDatabase {
t.column("header", .text).notNull()
t.column("headerStatic", .text).notNull()
t.column("emojis", .blob).notNull()
t.column("followRequestCount", .integer).notNull()
}
}

View file

@ -92,7 +92,8 @@ public extension IdentityDatabase {
avatarStatic: account.avatarStatic,
header: account.header,
headerStatic: account.headerStatic,
emojis: account.emojis)
emojis: account.emojis,
followRequestCount: account.source?.followRequestsCount ?? 0)
.save)
.ignoreOutput()
.eraseToAnyPublisher()

View file

@ -40,8 +40,8 @@ extension CollectionItem {
identityContext: identityContext,
status: status,
configuration: configuration)
case let .account(account):
return AccountView.estimatedHeight(width: width, account: account)
case let .account(account, configuration):
return AccountView.estimatedHeight(width: width, account: account, configuration: configuration)
case .loadMore:
return LoadMoreView.estimatedHeight
case let .notification(notification, configuration):

View file

@ -70,6 +70,7 @@
"emoji.system-group.flags" = "Flags";
"error" = "Error";
"favorites" = "Favorites";
"follow-requests" = "Follow Requests";
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
"registration.username" = "Username";
"registration.email" = "Email";

View file

@ -3,12 +3,6 @@
import Foundation
public final class Account: Codable, Identifiable {
public struct Field: Codable, Hashable {
public let name: String
public let value: HTML
public let verifiedAt: Date?
}
public let id: Id
public let username: String
public let acct: String
@ -29,6 +23,7 @@ public final class Account: Codable, Identifiable {
@DecodableDefault.False public private(set) var bot: Bool
@DecodableDefault.False public private(set) var discoverable: Bool
public var moved: Account?
public var source: Source?
public init(id: Id,
username: String,
@ -75,6 +70,21 @@ public final class Account: Codable, Identifiable {
public extension Account {
typealias Id = String
struct Field: Codable, Hashable {
public let name: String
public let value: HTML
public let verifiedAt: Date?
}
struct Source: Codable, Hashable {
public let note: String?
public let fields: [Field]
public let privacy: Status.Visibility?
public let sensitive: Bool?
public let language: String?
public let followRequestsCount: Int?
}
}
extension Account: Hashable {

View file

@ -11,6 +11,7 @@ public enum AccountsEndpoint {
case blocks
case accountsFollowers(id: Account.Id)
case accountsFollowing(id: Account.Id)
case followRequests
}
extension AccountsEndpoint: Endpoint {
@ -20,7 +21,7 @@ extension AccountsEndpoint: Endpoint {
switch self {
case .rebloggedBy, .favouritedBy:
return defaultContext + ["statuses"]
case .mutes, .blocks:
case .mutes, .blocks, .followRequests:
return defaultContext
case .accountsFollowers, .accountsFollowing:
return defaultContext + ["accounts"]
@ -41,6 +42,8 @@ extension AccountsEndpoint: Endpoint {
return [id, "followers"]
case let .accountsFollowing(id):
return [id, "following"]
case .followRequests:
return ["follow_requests"]
}
}

View file

@ -14,13 +14,20 @@ public enum RelationshipEndpoint {
case accountsPin(id: Account.Id)
case accountsUnpin(id: Account.Id)
case note(String, id: Account.Id)
case acceptFollowRequest(id: Account.Id)
case rejectFollowRequest(id: Account.Id)
}
extension RelationshipEndpoint: Endpoint {
public typealias ResultType = Relationship
public var context: [String] {
defaultContext + ["accounts"]
switch self {
case .acceptFollowRequest, .rejectFollowRequest:
return defaultContext + ["follow_requests"]
default:
return defaultContext + ["accounts"]
}
}
public var pathComponentsInContext: [String] {
@ -43,6 +50,10 @@ extension RelationshipEndpoint: Endpoint {
return [id, "unpin"]
case let .note(_, id):
return [id, "note"]
case let .acceptFollowRequest(id):
return [id, "authorize"]
case let .rejectFollowRequest(id):
return [id, "reject"]
}
}

View file

@ -0,0 +1,14 @@
// Copyright © 2021 Metabolist. All rights reserved.
import MastodonAPI
extension AccountsEndpoint {
var configuration: CollectionItem.AccountConfiguration {
switch self {
case .followRequests:
return .followRequest
default:
return .withNote
}
}
}

View file

@ -32,7 +32,7 @@ public struct AccountListService {
return $0 + $1.filter { !presentIds.contains($0.id) }
}
.map { [.init(items: $0.map(CollectionItem.account))] }
.map { [.init(items: $0.map { CollectionItem.account($0, endpoint.configuration) })] }
.eraseToAnyPublisher()
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)

View file

@ -92,6 +92,14 @@ public extension AccountService {
relationshipAction(.note(note, id: account.id))
}
func acceptFollowRequest() -> AnyPublisher<Never, Error> {
relationshipAction(.acceptFollowRequest(id: account.id))
}
func rejectFollowRequest() -> AnyPublisher<Never, Error> {
relationshipAction(.rejectFollowRequest(id: account.id))
}
func report(_ elements: ReportElements) -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher()
}

View file

@ -242,11 +242,12 @@ public extension IdentityService {
TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func service(accountList: AccountsEndpoint) -> AccountListService {
func service(accountList: AccountsEndpoint, titleComponents: [String]? = nil) -> AccountListService {
AccountListService(
endpoint: accountList,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
contentDatabase: contentDatabase,
titleComponents: titleComponents)
}
func exploreService() -> ExploreService {

View file

@ -40,8 +40,9 @@ final class MainNavigationViewController: UITabBarController {
}
.store(in: &cancellables)
viewModel.timelineNavigations
.sink { [weak self] _ in self?.selectedIndex = 0 }
viewModel.timelineNavigations.map { _ in }
.merge(with: viewModel.followRequestNavigations.map { _ in })
.sink { [weak self] in self?.selectedIndex = 0 }
.store(in: &cancellables)
}

View file

@ -82,6 +82,15 @@ final class TimelinesViewController: UIPageViewController {
self.show(vc, sender: self)
}
.store(in: &cancellables)
viewModel.followRequestNavigations.sink { [weak self] in
guard let self = self else { return }
let vc = TableViewController(viewModel: $0, rootViewModel: self.rootViewModel)
self.show(vc, sender: self)
}
.store(in: &cancellables)
}
}

View file

@ -5,9 +5,10 @@ import Foundation
import Mastodon
import ServiceLayer
public struct AccountViewModel: CollectionItemViewModel {
public final class AccountViewModel: CollectionItemViewModel, ObservableObject {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
public let identityContext: IdentityContext
public internal(set) var configuration = CollectionItem.AccountConfiguration.withNote
private let accountService: AccountService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
@ -138,6 +139,30 @@ public extension AccountViewModel {
ignorableOutputEvent(accountService.set(note: note))
}
func acceptFollowRequest() {
ignorableOutputEvent(
accountService.acceptFollowRequest()
.collect()
.flatMap { [weak self] _ -> AnyPublisher<Never, Error> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return self.identityContext.service.verifyCredentials()
}
.eraseToAnyPublisher())
}
func rejectFollowRequest() {
ignorableOutputEvent(
accountService.rejectFollowRequest()
.collect()
.flatMap { [weak self] _ -> AnyPublisher<Never, Error> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return self.identityContext.service.verifyCredentials()
}
.eraseToAnyPublisher())
}
func domainBlock() {
ignorableOutputEvent(accountService.domainBlock())
}

View file

@ -138,7 +138,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
case let .loadMore(loadMore):
lastSelectedLoadMore = loadMore
(viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loadMore()
case let .account(account):
case let .account(account, _):
eventsSubject.send(
.navigation(.profile(collectionService
.navigationService
@ -225,16 +225,19 @@ extension CollectionItemsViewModel: CollectionViewModel {
cache(viewModel: viewModel, forItem: item)
return viewModel
case let .account(account):
if let cachedViewModel = cachedViewModel {
return cachedViewModel
case let .account(account, configuration):
let viewModel: AccountViewModel
if let cachedViewModel = cachedViewModel as? AccountViewModel {
viewModel = cachedViewModel
} else {
viewModel = AccountViewModel(
accountService: collectionService.navigationService.accountService(account: account),
identityContext: identityContext)
cache(viewModel: viewModel, forItem: item)
}
let viewModel = AccountViewModel(
accountService: collectionService.navigationService.accountService(account: account),
identityContext: identityContext)
cache(viewModel: viewModel, forItem: item)
viewModel.configuration = configuration
return viewModel
case let .notification(notification, statusConfiguration):

View file

@ -8,6 +8,7 @@ import ServiceLayer
public final class NavigationViewModel: ObservableObject {
public let identityContext: IdentityContext
public let timelineNavigations: AnyPublisher<Timeline, Never>
public let followRequestNavigations: AnyPublisher<CollectionViewModel, Never>
@Published public private(set) var recentIdentities = [Identity]()
@Published public var presentingSecondaryNavigation = false
@ -38,11 +39,13 @@ public final class NavigationViewModel: ObservableObject {
}()
private let timelineNavigationsSubject = PassthroughSubject<Timeline, Never>()
private let followRequestNavigationsSubject = PassthroughSubject<CollectionViewModel, Never>()
private var cancellables = Set<AnyCancellable>()
public init(identityContext: IdentityContext) {
self.identityContext = identityContext
timelineNavigations = timelineNavigationsSubject.eraseToAnyPublisher()
followRequestNavigations = followRequestNavigationsSubject.eraseToAnyPublisher()
identityContext.$identity
.sink { [weak self] _ in self?.objectWillChange.send() }
@ -121,6 +124,17 @@ public extension NavigationViewModel {
timelineNavigationsSubject.send(timeline)
}
func navigateToFollowerRequests() {
let followRequestsViewModel = CollectionItemsViewModel(
collectionService: identityContext.service.service(
accountList: .followRequests,
titleComponents: ["follow-requests"]),
identityContext: identityContext)
presentingSecondaryNavigation = false
followRequestNavigationsSubject.send(followRequestsViewModel)
}
func viewModel(timeline: Timeline) -> CollectionItemsViewModel {
CollectionItemsViewModel(
collectionService: identityContext.service.service(timeline: timeline),

View file

@ -3,12 +3,15 @@
import Kingfisher
import Mastodon
import UIKit
import ViewModels
final class AccountView: UIView {
let avatarImageView = AnimatedImageView()
let displayNameLabel = UILabel()
let accountLabel = UILabel()
let noteTextView = TouchFallthroughTextView()
let acceptFollowRequestButton = UIButton()
let rejectFollowRequestButton = UIButton()
private var accountConfiguration: AccountContentConfiguration
@ -28,12 +31,21 @@ final class AccountView: UIView {
}
extension AccountView {
static func estimatedHeight(width: CGFloat, account: Account) -> CGFloat {
.defaultSpacing * 2
+ .compactSpacing * 2
static func estimatedHeight(width: CGFloat,
account: Account,
configuration: CollectionItem.AccountConfiguration) -> CGFloat {
var height = CGFloat.defaultSpacing * 2
+ .compactSpacing
+ account.displayName.height(width: width, font: .preferredFont(forTextStyle: .headline))
+ account.acct.height(width: width, font: .preferredFont(forTextStyle: .subheadline))
+ account.note.attributed.string.height(width: width, font: .preferredFont(forTextStyle: .callout))
if configuration == .withNote {
height += .compactSpacing + account.note.attributed.string.height(
width: width,
font: .preferredFont(forTextStyle: .callout))
}
return max(height, .avatarDimension + .defaultSpacing * 2)
}
}
@ -71,17 +83,24 @@ private extension AccountView {
func initialSetup() {
let stackView = UIStackView()
addSubview(avatarImageView)
addSubview(stackView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .defaultSpacing
stackView.alignment = .top
stackView.addArrangedSubview(avatarImageView)
avatarImageView.layer.cornerRadius = .avatarDimension / 2
avatarImageView.clipsToBounds = true
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .compactSpacing
stackView.addArrangedSubview(displayNameLabel)
stackView.addArrangedSubview(accountLabel)
stackView.addArrangedSubview(noteTextView)
let verticalStackView = UIStackView()
stackView.addArrangedSubview(verticalStackView)
verticalStackView.translatesAutoresizingMaskIntoConstraints = false
verticalStackView.axis = .vertical
verticalStackView.spacing = .compactSpacing
verticalStackView.addArrangedSubview(displayNameLabel)
verticalStackView.addArrangedSubview(accountLabel)
verticalStackView.addArrangedSubview(noteTextView)
displayNameLabel.numberOfLines = 0
displayNameLabel.font = .preferredFont(forTextStyle: .headline)
displayNameLabel.adjustsFontForContentSizeCategory = true
@ -92,46 +111,82 @@ private extension AccountView {
noteTextView.backgroundColor = .clear
noteTextView.delegate = self
let largeTitlePointSize = UIFont.preferredFont(forTextStyle: .largeTitle).pointSize
stackView.addArrangedSubview(acceptFollowRequestButton)
acceptFollowRequestButton.setImage(
UIImage(systemName: "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(pointSize: largeTitlePointSize)),
for: .normal)
acceptFollowRequestButton.setContentHuggingPriority(.required, for: .horizontal)
acceptFollowRequestButton.addAction(
UIAction { [weak self] _ in self?.accountConfiguration.viewModel.acceptFollowRequest() },
for: .touchUpInside)
stackView.addArrangedSubview(rejectFollowRequestButton)
rejectFollowRequestButton.setImage(
UIImage(systemName: "xmark.circle",
withConfiguration: UIImage.SymbolConfiguration(pointSize: largeTitlePointSize)),
for: .normal)
rejectFollowRequestButton.tintColor = .systemRed
rejectFollowRequestButton.setContentHuggingPriority(.required, for: .horizontal)
rejectFollowRequestButton.addAction(
UIAction { [weak self] _ in self?.accountConfiguration.viewModel.rejectFollowRequest() },
for: .touchUpInside)
NSLayoutConstraint.activate([
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: readableContentGuide.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing),
acceptFollowRequestButton.widthAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
acceptFollowRequestButton.heightAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
rejectFollowRequestButton.widthAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
rejectFollowRequestButton.heightAnchor.constraint(greaterThanOrEqualToConstant: .avatarDimension),
stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor)
])
}
func applyAccountConfiguration() {
avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL(profile: false))
let viewModel = accountConfiguration.viewModel
if accountConfiguration.viewModel.displayName.isEmpty {
avatarImageView.kf.setImage(with: viewModel.avatarURL(profile: false))
if viewModel.displayName.isEmpty {
displayNameLabel.isHidden = true
} else {
let mutableDisplayName = NSMutableAttributedString(string: accountConfiguration.viewModel.displayName)
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
mutableDisplayName.insert(emojis: accountConfiguration.viewModel.emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: viewModel.emojis, view: displayNameLabel)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
}
accountLabel.text = accountConfiguration.viewModel.accountName
accountLabel.text = viewModel.accountName
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
let mutableNote = NSMutableAttributedString(attributedString: accountConfiguration.viewModel.note)
let noteRange = NSRange(location: 0, length: mutableNote.length)
if viewModel.configuration == .withNote {
let noteFont = UIFont.preferredFont(forTextStyle: .callout)
let mutableNote = NSMutableAttributedString(attributedString: viewModel.note)
let noteRange = NSRange(location: 0, length: mutableNote.length)
mutableNote.removeAttribute(.font, range: noteRange)
mutableNote.addAttributes(
[.font: noteFont as Any,
.foregroundColor: UIColor.label],
range: noteRange)
mutableNote.insert(emojis: accountConfiguration.viewModel.emojis, view: noteTextView)
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
mutableNote.removeAttribute(.font, range: noteRange)
mutableNote.addAttributes(
[.font: noteFont as Any,
.foregroundColor: UIColor.label],
range: noteRange)
mutableNote.insert(emojis: viewModel.emojis, view: noteTextView)
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
noteTextView.attributedText = mutableNote
noteTextView.attributedText = mutableNote
noteTextView.isHidden = false
} else {
noteTextView.isHidden = true
}
let isFollowRequest = viewModel.configuration == .followRequest
acceptFollowRequestButton.isHidden = !isFollowRequest
rejectFollowRequestButton.isHidden = !isFollowRequest
}
}

View file

@ -68,6 +68,22 @@ struct SecondaryNavigationView: View {
}
}
}
if let followRequestCount = viewModel.identityContext.identity.account?.followRequestCount,
followRequestCount > 0 {
Button {
viewModel.navigateToFollowerRequests()
} label: {
Label {
HStack {
Text("follow-requests").foregroundColor(.primary)
Spacer()
Text(verbatim: String(followRequestCount))
}
} icon: {
Image(systemName: "person.badge.plus")
}
}
}
}
Section {
NavigationLink(