From 2c1c42ea71e0fd9d5f626b6cf6b0dbeaec29de27 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Thu, 12 Nov 2020 00:32:18 -0800 Subject: [PATCH] Follow / unfollow --- DB/Sources/DB/Content/ContentDatabase.swift | 15 ++++ Localizations/Localizable.strings | 5 ++ .../Endpoints/RelationshipEndpoint.swift | 34 ++++++++ .../Services/AccountService.swift | 17 ++++ .../Sources/ViewModels/AccountViewModel.swift | 10 +++ Views/AccountHeaderView.swift | 78 +++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 6eeb3d6..5996e1f 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -218,6 +218,21 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func unfollow(id: Account.Id) -> AnyPublisher { + 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 { databaseWriter.writePublisher { try list.save($0) diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index a39acf6..e3ac5a4 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -1,9 +1,13 @@ // Copyright © 2020 Metabolist. All rights reserved. "account.field.verified" = "Verified %@"; +"account.follow" = "Follow"; +"account.following" = "Following"; +"account.request" = "Request"; "account.statuses" = "Posts"; "account.statuses-and-replies" = "Posts & Replies"; "account.media" = "Media"; +"account.unfollow-account" = "Unfollow %@"; "add" = "Add"; "apns-default-message" = "New notification"; "add-identity.instance-url" = "Instance URL"; @@ -14,6 +18,7 @@ "add-identity.unable-to-connect-to-instance" = "Unable to connect to instance"; "attachment.sensitive-content" = "Sensitive content"; "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.username" = "Username"; "registration.email" = "Email"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift new file mode 100644 index 0000000..5467c12 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/RelationshipEndpoint.swift @@ -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 + } + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index 8e39f41..d0f70f7 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -28,3 +28,20 @@ public struct AccountService { self.contentDatabase = contentDatabase } } + +public extension AccountService { + func follow() -> AnyPublisher { + mastodonAPIClient.request(RelationshipEndpoint.accountsFollow(id: account.id)) + .flatMap { contentDatabase.insert(relationships: [$0]) } + .eraseToAnyPublisher() + } + + func unfollow() -> AnyPublisher { + mastodonAPIClient.request(RelationshipEndpoint.accountsUnfollow(id: account.id)) + .flatMap { + contentDatabase.insert(relationships: [$0]) + .merge(with: contentDatabase.unfollow(id: account.id)) + } + .eraseToAnyPublisher() + } +} diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 12dc13c..593c2cd 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -36,6 +36,8 @@ public extension AccountViewModel { var isLocked: Bool { accountService.account.locked } + var relationship: Relationship? { accountService.relationship } + var identityProofs: [IdentityProof] { accountService.identityProofs } var fields: [Account.Field] { accountService.account.fields } @@ -63,4 +65,12 @@ public extension AccountViewModel { .setFailureType(to: Error.self) .eraseToAnyPublisher()) } + + func follow() { + eventsSubject.send(accountService.follow().map { _ in .ignorableOutput }.eraseToAnyPublisher()) + } + + func unfollow() { + eventsSubject.send(accountService.unfollow().map { _ in .ignorableOutput }.eraseToAnyPublisher()) + } } diff --git a/Views/AccountHeaderView.swift b/Views/AccountHeaderView.swift index 159a5ce..5501ecb 100644 --- a/Views/AccountHeaderView.swift +++ b/Views/AccountHeaderView.swift @@ -9,6 +9,9 @@ final class AccountHeaderView: UIView { let headerButton = UIButton() let avatarImageView = UIImageView() let avatarButton = UIButton() + let relationshipButtonsStackView = UIStackView() + let followButton = UIButton(type: .system) + let unfollowButton = UIButton(type: .system) let displayNameLabel = UILabel() let accountStackView = UIStackView() let accountLabel = UILabel() @@ -25,6 +28,20 @@ final class AccountHeaderView: UIView { avatarImageView.kf.setImage(with: accountViewModel.avatarURL(profile: true)) 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 { displayNameLabel.isHidden = true } else { @@ -99,6 +116,17 @@ final class AccountHeaderView: UIView { required init?(coder: NSCoder) { 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 { @@ -152,6 +180,50 @@ private extension AccountHeaderView { 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) baseStackView.translatesAutoresizingMaskIntoConstraints = false baseStackView.axis = .vertical @@ -231,6 +303,12 @@ private extension AccountHeaderView { avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), 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.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),