feat: add familiar followers UI component for ProfileCard

This commit is contained in:
CMK 2022-05-16 19:42:03 +08:00
parent 945f05703b
commit 531f71b77d
16 changed files with 342 additions and 35 deletions

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import MastodonUI
import MastodonSDK
enum DiscoverySection: CaseIterable {
// case posts
@ -22,9 +23,14 @@ extension DiscoverySection {
class Configuration {
weak var profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate?
let familiarFollowers: Published<[Mastodon.Entity.FamiliarFollowers]>.Publisher?
public init(profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate? = nil) {
public init(
profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate? = nil,
familiarFollowers: Published<[Mastodon.Entity.FamiliarFollowers]>.Publisher? = nil
) {
self.profileCardTableViewCellDelegate = profileCardTableViewCellDelegate
self.familiarFollowers = familiarFollowers
}
}
@ -57,6 +63,15 @@ extension DiscoverySection {
user: user,
profileCardTableViewCellDelegate: configuration.profileCardTableViewCellDelegate
)
// bind familiarFollowers
if let familiarFollowers = configuration.familiarFollowers {
familiarFollowers
.map { array in array.first(where: { $0.id == user.id }) }
.assign(to: \.familiarFollowers, on: cell.profileCardView.viewModel)
.store(in: &cell.disposeBag)
} else {
cell.profileCardView.viewModel.familiarFollowers = nil
}
}
context.authenticationService.activeMastodonAuthentication
.map { $0?.user }

View File

@ -19,7 +19,8 @@ extension DiscoveryForYouViewModel {
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration(
profileCardTableViewCellDelegate: profileCardTableViewCellDelegate
profileCardTableViewCellDelegate: profileCardTableViewCellDelegate,
familiarFollowers: $familiarFollowers
)
)

View File

@ -20,6 +20,9 @@ final class DiscoveryForYouViewModel {
// input
let context: AppContext
let userFetchedResultsController: UserFetchedResultsController
@MainActor
@Published var familiarFollowers: [Mastodon.Entity.FamiliarFollowers] = []
@Published var isFetching = false
// output
@ -48,12 +51,35 @@ final class DiscoveryForYouViewModel {
}
extension DiscoveryForYouViewModel {
@MainActor
func fetch() async throws {
guard !isFetching else { return }
isFetching = true
defer { isFetching = false }
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
throw APIService.APIError.implicit(.badRequest)
}
do {
let userIDs = try await fetchSuggestionAccounts()
let _familiarFollowersResponse = try? await context.apiService.familiarFollowers(
query: .init(ids: userIDs),
authenticationBox: authenticationBox
)
familiarFollowers = _familiarFollowersResponse?.value ?? []
userFetchedResultsController.userIDs = userIDs
} catch {
// do nothing
}
}
private func fetchSuggestionAccounts() async throws -> [Mastodon.Entity.Account.ID] {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
throw APIService.APIError.implicit(.badRequest)
}
do {
let response = try await context.apiService.suggestionAccountV2(
@ -61,15 +87,15 @@ extension DiscoveryForYouViewModel {
authenticationBox: authenticationBox
)
let userIDs = response.value.map { $0.account.id }
userFetchedResultsController.userIDs = userIDs
return userIDs
} catch {
// fallback V1
let response2 = try await context.apiService.suggestionAccount(
let response = try await context.apiService.suggestionAccount(
query: nil,
authenticationBox: authenticationBox
)
let userIDs = response2.value.map { $0.id }
userFetchedResultsController.userIDs = userIDs
let userIDs = response.value.map { $0.id }
return userIDs
}
}
}

View File

@ -80,7 +80,7 @@ extension APIService {
func familiarFollowers(
query: Mastodon.API.Account.FamiliarFollowersQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> {
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.FamiliarFollowers]> {
let response = try await Mastodon.API.Account.familiarFollowers(
session: session,
domain: authenticationBox.domain,
@ -88,20 +88,23 @@ extension APIService {
authorization: authenticationBox.userAuthorization
).singleOutput()
// let managedObjectContext = backgroundManagedObjectContext
// try await managedObjectContext.performChanges {
// for entity in response.value {
// _ = Persistence.MastodonUser.createOrMerge(
// in: managedObjectContext,
// context: Persistence.MastodonUser.PersistContext(
// domain: authenticationBox.domain,
// entity: entity.account,
// cache: nil,
// networkDate: response.networkDate
// )
// )
// } // end for in
// }
let managedObjectContext = backgroundManagedObjectContext
try await managedObjectContext.performChanges {
for entity in response.value {
for account in entity.accounts {
_ = Persistence.MastodonUser.createOrMerge(
in: managedObjectContext,
context: Persistence.MastodonUser.PersistContext(
domain: authenticationBox.domain,
entity: account,
cache: nil,
networkDate: response.networkDate
)
)
} // end for account in
} // end for entity in
}
return response
}

View File

@ -36,7 +36,7 @@ extension Mastodon.API.Account {
domain: String,
query: FamiliarFollowersQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.FamiliarFollowers]>, Error> {
let request = Mastodon.API.get(
url: familiarFollowersEndpointURL(domain: domain),
query: query,
@ -44,24 +44,23 @@ extension Mastodon.API.Account {
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.FamiliarFollowers].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public struct FamiliarFollowersQuery: GetQuery {
public let accounts: [Mastodon.Entity.Account.ID]
public let ids: [Mastodon.Entity.Account.ID]
public init(accounts: [Mastodon.Entity.Account.ID]) {
self.accounts = accounts
public init(ids: [Mastodon.Entity.Account.ID]) {
self.ids = ids
}
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
let accountsValue = accounts.joined(separator: ",")
if !accountsValue.isEmpty {
items.append(URLQueryItem(name: "accounts", value: accountsValue))
for id in ids {
items.append(URLQueryItem(name: "id[]", value: id))
}
guard !items.isEmpty else { return nil }
return items

View File

@ -0,0 +1,24 @@
//
// Mastodon+Entity+FamiliarFollowers.swift
//
//
// Created by MainasuK on 2022-5-16.
//
import Foundation
extension Mastodon.Entity {
/// FamiliarFollowers
///
/// - Since: 3.5.2
/// - Version: 3.5.2
/// # Last Update
/// 2022/5/16
/// # Reference
/// [Document](TBD)
public class FamiliarFollowers: Codable {
public let id: Mastodon.Entity.Account.ID
public let accounts: [Mastodon.Entity.Account]
}
}

View File

@ -0,0 +1,30 @@
//
// Mastodon+Entity+Account.swift
//
//
// Created by MainasuK on 2022-5-16.
//
import Foundation
import MastodonSDK
extension Mastodon.Entity.Account {
public var displayNameWithFallback: String {
if displayName.isEmpty {
return username
} else {
return displayName
}
}
}
extension Mastodon.Entity.Account {
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
}

View File

@ -0,0 +1,20 @@
//
// FamiliarFollowersDashboardView+Configuration.swift
//
//
// Created by MainasuK on 2022-5-16.
//
import UIKit
import MastodonSDK
extension FamiliarFollowersDashboardView {
public func configure(familiarFollowers: Mastodon.Entity.FamiliarFollowers?) {
assert(Thread.isMainThread)
let accounts = familiarFollowers?.accounts.prefix(4) ?? []
viewModel.avatarURLs = accounts.map { $0.avatarImageURL() }
viewModel.names = accounts.map { $0.displayNameWithFallback }
}
}

View File

@ -0,0 +1,63 @@
//
// FamiliarFollowersDashboardView+ViewModel.swift
//
//
// Created by MainasuK on 2022-5-16.
//
import os.log
import UIKit
import Combine
extension FamiliarFollowersDashboardView {
public final class ViewModel: ObservableObject {
public var disposeBag = Set<AnyCancellable>()
let logger = Logger(subsystem: "FamiliarFollowersDashboardView", category: "ViewModel")
@Published var avatarURLs: [URL?] = []
@Published var names: [String] = []
@Published var backgroundColor: UIColor?
}
}
extension FamiliarFollowersDashboardView.ViewModel {
func bind(view: FamiliarFollowersDashboardView) {
Publishers.CombineLatest(
$avatarURLs,
$backgroundColor
)
.sink { avatarURLs, backgroundColor in
view.avatarContainerView.subviews.forEach { $0.removeFromSuperview() }
for (i, avatarURL) in avatarURLs.enumerated() {
let avatarButton = AvatarButton()
let origin = CGPoint(x: 20 * i, y: 0)
let size = CGSize(width: 32, height: 32)
avatarButton.size = size
avatarButton.frame = CGRect(origin: origin, size: size)
view.avatarContainerView.addSubview(avatarButton)
avatarButton.avatarImageView.configure(configuration: .init(url: avatarURL))
avatarButton.avatarImageView.configure(
cornerConfiguration: .init(
corner: .fixed(radius: 7),
border: .init(
color: backgroundColor ?? .clear,
width: 1
)
)
)
}
view.avatarContainerViewWidthLayoutConstraint.constant = CGFloat(12 + 20 * avatarURLs.count)
}
.store(in: &disposeBag)
$names
.sink { names in
// TODO: i18n
let description = "Followed by" + names.joined(separator: ", ")
view.descriptionLabel.text = description
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,85 @@
//
// FamiliarFollowersDashboardView.swift
//
//
// Created by MainasuK on 2022-5-16.
//
import UIKit
import MastodonAsset
public final class FamiliarFollowersDashboardView: UIView {
let avatarContainerView = UIView()
var avatarContainerViewWidthLayoutConstraint: NSLayoutConstraint!
let descriptionLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
label.text = "Followed by Pixelflowers, Lees Food, and 4 other mutuals"
label.textColor = Asset.Colors.Label.secondary.color
label.numberOfLines = 0
return label
}()
public private(set) lazy var viewModel: ViewModel = {
let viewModel = ViewModel()
viewModel.bind(view: self)
return viewModel
}()
public func prepareForReuse() {
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension FamiliarFollowersDashboardView {
private func _init() {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 8
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
avatarContainerView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(avatarContainerView)
avatarContainerViewWidthLayoutConstraint = avatarContainerView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1)
NSLayoutConstraint.activate([
avatarContainerViewWidthLayoutConstraint,
avatarContainerView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1)
])
stackView.addArrangedSubview(descriptionLabel)
}
}
#if DEBUG
import SwiftUI
struct FamiliarFollowersDashboardView_Preview: PreviewProvider {
static var previews: some View {
UIViewPreview {
FamiliarFollowersDashboardView()
}
}
}
#endif

View File

@ -18,7 +18,7 @@ extension NotificationView {
public final class ViewModel: ObservableObject {
public var disposeBag = Set<AnyCancellable>()
let logger = Logger(subsystem: "StatusView", category: "ViewModel")
let logger = Logger(subsystem: "NotificationView", category: "ViewModel")
@Published public var userIdentifier: UserIdentifier? // me

View File

@ -10,6 +10,7 @@ import Combine
import CoreDataStack
import Meta
import MastodonMeta
import MastodonSDK
extension ProfileCardView {

View File

@ -13,6 +13,7 @@ import AlamofireImage
import CoreDataStack
import MastodonLocalization
import MastodonAsset
import MastodonSDK
extension ProfileCardView {
public class ViewModel: ObservableObject {
@ -44,6 +45,8 @@ extension ProfileCardView {
@Published public var groupedAccessibilityLabel = ""
@Published public var familiarFollowers: Mastodon.Entity.FamiliarFollowers?
init() {
backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
Publishers.CombineLatest(
@ -77,6 +80,7 @@ extension ProfileCardView.ViewModel {
bindBio(view: view)
bindRelationship(view: view)
bindDashboard(view: view)
bindFamiliarFollowers(view: view)
bindAccessibility(view: view)
}
@ -189,6 +193,18 @@ extension ProfileCardView.ViewModel {
.store(in: &disposeBag)
}
private func bindFamiliarFollowers(view: ProfileCardView) {
$familiarFollowers
.sink { familiarFollowers in
view.familiarFollowersDashboardViewAdaptiveMarginContainerView.isHidden = familiarFollowers.flatMap { $0.accounts.isEmpty } ?? true
view.familiarFollowersDashboardView.configure(familiarFollowers: familiarFollowers)
}
.store(in: &disposeBag)
$backgroundColor
.assign(to: \.backgroundColor, on: view.familiarFollowersDashboardView.viewModel)
.store(in: &disposeBag)
}
private func bindAccessibility(view: ProfileCardView) {
let authorAccessibilityLabel = Publishers.CombineLatest(
$authorName,

View File

@ -94,6 +94,9 @@ public final class ProfileCardView: UIView {
return button
}()
let familiarFollowersDashboardViewAdaptiveMarginContainerView = AdaptiveMarginContainerView()
let familiarFollowersDashboardView = FamiliarFollowersDashboardView()
public private(set) lazy var viewModel: ViewModel = {
let viewModel = ViewModel()
viewModel.bind(view: self)
@ -126,7 +129,7 @@ extension ProfileCardView {
bioMetaText.textView.isUserInteractionEnabled = false
statusDashboardView.isUserInteractionEnabled = false
// container: V - [ bannerContainer | authorContainer | bioMetaText | infoContainer ]
// container: V - [ bannerContainer | authorContainer | bioMetaText | infoContainer | familiarFollowersDashboardView ]
container.axis = .vertical
container.spacing = 8
container.translatesAutoresizingMaskIntoConstraints = false
@ -211,7 +214,7 @@ extension ProfileCardView {
container.addArrangedSubview(bioMetaTextAdaptiveMarginContainerView)
container.setCustomSpacing(16, after: bioMetaTextAdaptiveMarginContainerView)
// infoContainer: H - [ statusDashboardView | (spacer) | relationshipActionButton ]
// infoContainer: H - [ statusDashboardView | (spacer) | relationshipActionButton]
infoContainer.axis = .horizontal
infoContainer.spacing = 8
infoContainerAdaptiveMarginContainerView.contentView = infoContainer
@ -237,6 +240,10 @@ extension ProfileCardView {
relationshipActionButtonShadowContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: ProfileCardView.friendshipActionButtonSize.width).priority(.required - 1),
relationshipActionButtonShadowContainer.heightAnchor.constraint(equalToConstant: ProfileCardView.friendshipActionButtonSize.height).priority(.required - 1),
])
familiarFollowersDashboardViewAdaptiveMarginContainerView.contentView = familiarFollowersDashboardView
familiarFollowersDashboardViewAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
container.addArrangedSubview(familiarFollowersDashboardViewAdaptiveMarginContainerView)
let bottomPadding = UIView()
bottomPadding.translatesAutoresizingMaskIntoConstraints = false

View File

@ -44,6 +44,11 @@ extension AvatarImageView {
}
}
private func setup(border: CornerConfiguration.Border?) {
layer.borderColor = border?.color.cgColor
layer.borderWidth = border?.width ?? .zero
}
}
extension AvatarImageView {
@ -107,9 +112,14 @@ extension AvatarImageView {
extension AvatarImageView {
public struct CornerConfiguration {
public let corner: Corner
public let border: Border?
public init(corner: Corner = .circle) {
public init(
corner: Corner = .circle,
border: Border? = nil
) {
self.corner = corner
self.border = border
}
public enum Corner {
@ -117,10 +127,16 @@ extension AvatarImageView {
case fixed(radius: CGFloat)
case scale(ratio: Int = 4) // width / ratio
}
public struct Border {
public let color: UIColor
public let width: CGFloat
}
}
public func configure(cornerConfiguration: CornerConfiguration) {
self.cornerConfiguration = cornerConfiguration
setup(corner: cornerConfiguration.corner)
setup(border: cornerConfiguration.border)
}
}

View File

@ -7,6 +7,7 @@
import UIKit
import CoreDataStack
import MastodonSDK
extension ProfileCardTableViewCell {