Follow / unfollow

This commit is contained in:
Justin Mazzocchi 2020-11-12 00:32:18 -08:00
parent ad4b238883
commit 2c1c42ea71
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
6 changed files with 159 additions and 0 deletions

View file

@ -218,6 +218,21 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func unfollow(id: Account.Id) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
let statusIds = try Status.Id.fetchAll(
$0,
StatusRecord.filter(StatusRecord.Columns.accountId == id).select(StatusRecord.Columns.id))
try TimelineStatusJoin.filter(
TimelineStatusJoin.Columns.timelineId == Timeline.home.id
&& statusIds.contains(TimelineStatusJoin.Columns.statusId))
.deleteAll($0)
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> { func append(accounts: [Account], toList list: AccountList) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher { databaseWriter.writePublisher {
try list.save($0) try list.save($0)

View file

@ -1,9 +1,13 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
"account.field.verified" = "Verified %@"; "account.field.verified" = "Verified %@";
"account.follow" = "Follow";
"account.following" = "Following";
"account.request" = "Request";
"account.statuses" = "Posts"; "account.statuses" = "Posts";
"account.statuses-and-replies" = "Posts & Replies"; "account.statuses-and-replies" = "Posts & Replies";
"account.media" = "Media"; "account.media" = "Media";
"account.unfollow-account" = "Unfollow %@";
"add" = "Add"; "add" = "Add";
"apns-default-message" = "New notification"; "apns-default-message" = "New notification";
"add-identity.instance-url" = "Instance URL"; "add-identity.instance-url" = "Instance URL";
@ -14,6 +18,7 @@
"add-identity.unable-to-connect-to-instance" = "Unable to connect to instance"; "add-identity.unable-to-connect-to-instance" = "Unable to connect to instance";
"attachment.sensitive-content" = "Sensitive content"; "attachment.sensitive-content" = "Sensitive content";
"attachment.media-hidden" = "Media hidden"; "attachment.media-hidden" = "Media hidden";
"cancel" = "Cancel";
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue"; "registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
"registration.username" = "Username"; "registration.username" = "Username";
"registration.email" = "Email"; "registration.email" = "Email";

View file

@ -0,0 +1,34 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum RelationshipEndpoint {
case accountsFollow(id: Account.Id)
case accountsUnfollow(id: Account.Id)
}
extension RelationshipEndpoint: Endpoint {
public typealias ResultType = Relationship
public var context: [String] {
defaultContext + ["accounts"]
}
public var pathComponentsInContext: [String] {
switch self {
case let .accountsFollow(id):
return [id, "follow"]
case let .accountsUnfollow(id):
return [id, "unfollow"]
}
}
public var method: HTTPMethod {
switch self {
case .accountsFollow, .accountsUnfollow:
return .post
}
}
}

View file

@ -28,3 +28,20 @@ public struct AccountService {
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase
} }
} }
public extension AccountService {
func follow() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(RelationshipEndpoint.accountsFollow(id: account.id))
.flatMap { contentDatabase.insert(relationships: [$0]) }
.eraseToAnyPublisher()
}
func unfollow() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(RelationshipEndpoint.accountsUnfollow(id: account.id))
.flatMap {
contentDatabase.insert(relationships: [$0])
.merge(with: contentDatabase.unfollow(id: account.id))
}
.eraseToAnyPublisher()
}
}

View file

@ -36,6 +36,8 @@ public extension AccountViewModel {
var isLocked: Bool { accountService.account.locked } var isLocked: Bool { accountService.account.locked }
var relationship: Relationship? { accountService.relationship }
var identityProofs: [IdentityProof] { accountService.identityProofs } var identityProofs: [IdentityProof] { accountService.identityProofs }
var fields: [Account.Field] { accountService.account.fields } var fields: [Account.Field] { accountService.account.fields }
@ -63,4 +65,12 @@ public extension AccountViewModel {
.setFailureType(to: Error.self) .setFailureType(to: Error.self)
.eraseToAnyPublisher()) .eraseToAnyPublisher())
} }
func follow() {
eventsSubject.send(accountService.follow().map { _ in .ignorableOutput }.eraseToAnyPublisher())
}
func unfollow() {
eventsSubject.send(accountService.unfollow().map { _ in .ignorableOutput }.eraseToAnyPublisher())
}
} }

View file

@ -9,6 +9,9 @@ final class AccountHeaderView: UIView {
let headerButton = UIButton() let headerButton = UIButton()
let avatarImageView = UIImageView() let avatarImageView = UIImageView()
let avatarButton = UIButton() let avatarButton = UIButton()
let relationshipButtonsStackView = UIStackView()
let followButton = UIButton(type: .system)
let unfollowButton = UIButton(type: .system)
let displayNameLabel = UILabel() let displayNameLabel = UILabel()
let accountStackView = UIStackView() let accountStackView = UIStackView()
let accountLabel = UILabel() let accountLabel = UILabel()
@ -25,6 +28,20 @@ final class AccountHeaderView: UIView {
avatarImageView.kf.setImage(with: accountViewModel.avatarURL(profile: true)) avatarImageView.kf.setImage(with: accountViewModel.avatarURL(profile: true))
avatarImageView.tag = accountViewModel.avatarURL(profile: true).hashValue avatarImageView.tag = accountViewModel.avatarURL(profile: true).hashValue
if !accountViewModel.isSelf, let relationship = accountViewModel.relationship {
followButton.setTitle(
NSLocalizedString(
accountViewModel.isLocked ? "account.request" : "account.follow",
comment: ""),
for: .normal)
followButton.isHidden = relationship.following
unfollowButton.isHidden = !relationship.following
relationshipButtonsStackView.isHidden = false
} else {
relationshipButtonsStackView.isHidden = true
}
if accountViewModel.displayName.isEmpty { if accountViewModel.displayName.isEmpty {
displayNameLabel.isHidden = true displayNameLabel.isHidden = true
} else { } else {
@ -99,6 +116,17 @@ final class AccountHeaderView: UIView {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func layoutSubviews() {
super.layoutSubviews()
for button in [followButton, unfollowButton] {
let inset = (followButton.bounds.height - (button.titleLabel?.bounds.height ?? 0)) / 2
button.contentEdgeInsets = .init(top: 0, left: inset, bottom: 0, right: inset)
button.layer.cornerRadius = button.bounds.height / 2
}
}
} }
extension AccountHeaderView: UITextViewDelegate { extension AccountHeaderView: UITextViewDelegate {
@ -152,6 +180,50 @@ private extension AccountHeaderView {
avatarButton.addAction(UIAction { [weak self] _ in self?.viewModel?.presentAvatar() }, for: .touchUpInside) avatarButton.addAction(UIAction { [weak self] _ in self?.viewModel?.presentAvatar() }, for: .touchUpInside)
addSubview(relationshipButtonsStackView)
relationshipButtonsStackView.translatesAutoresizingMaskIntoConstraints = false
relationshipButtonsStackView.spacing = .defaultSpacing
relationshipButtonsStackView.addArrangedSubview(UIView())
for button in [followButton, unfollowButton] {
relationshipButtonsStackView.addArrangedSubview(button)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.backgroundColor = .secondarySystemBackground
}
followButton.setImage(
UIImage(
systemName: "person.badge.plus",
withConfiguration: UIImage.SymbolConfiguration(scale: .small)),
for: .normal)
followButton.addAction(
UIAction { [weak self] _ in self?.viewModel?.accountViewModel?.follow() },
for: .touchUpInside)
unfollowButton.setImage(
UIImage(
systemName: "checkmark",
withConfiguration: UIImage.SymbolConfiguration(scale: .small)),
for: .normal)
unfollowButton.setTitle(NSLocalizedString("account.following", comment: ""), for: .normal)
unfollowButton.showsMenuAsPrimaryAction = true
unfollowButton.menu = UIMenu(children: [UIDeferredMenuElement { [weak self] completion in
guard let accountViewModel = self?.viewModel?.accountViewModel else { return }
let unfollowAction = UIAction(
title: String.localizedStringWithFormat(
NSLocalizedString("account.unfollow-account", comment: ""),
accountViewModel.accountName),
image: UIImage(systemName: "person.badge.minus"),
attributes: .destructive) { _ in
accountViewModel.unfollow()
}
completion([unfollowAction])
},
UIAction(title: NSLocalizedString("cancel", comment: "")) { _ in }])
addSubview(baseStackView) addSubview(baseStackView)
baseStackView.translatesAutoresizingMaskIntoConstraints = false baseStackView.translatesAutoresizingMaskIntoConstraints = false
baseStackView.axis = .vertical baseStackView.axis = .vertical
@ -231,6 +303,12 @@ private extension AccountHeaderView {
avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor), avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
relationshipButtonsStackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
relationshipButtonsStackView.topAnchor.constraint(
equalTo: headerImageView.bottomAnchor,
constant: .defaultSpacing),
relationshipButtonsStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
relationshipButtonsStackView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
baseStackView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: .defaultSpacing), baseStackView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: .defaultSpacing),
baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),