From a7bab76f9670a2d64cff553ebbbeafce74429453 Mon Sep 17 00:00:00 2001 From: Nathan Mattes Date: Mon, 18 Sep 2023 16:12:42 +0200 Subject: [PATCH] Add a cell for profiles in search results (IOS-141) --- Mastodon.xcodeproj/project.pbxproj | 4 + .../SearchResultsProfileTableViewCell.swift | 170 ++++++++++++++++++ ...chResultsOverviewTableViewController.swift | 47 ++--- .../Entity/Mastodon+Entity+Account.swift | 7 + .../Extension/FLAnimatedImageView.swift | 4 +- 5 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index dc441b9e8..f4936e22f 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; }; D8BE30B32A179E26006B8270 /* SuggestionAccountTableViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */; }; D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */; }; + D8D688F62AB869CB000F651A /* SearchResultsProfileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */; }; D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; }; D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; }; D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; }; @@ -803,6 +804,7 @@ D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = ""; }; D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = ""; }; + D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsProfileTableViewCell.swift; sourceTree = ""; }; D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = ""; }; D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = ""; }; @@ -1811,6 +1813,7 @@ isa = PBXGroup; children = ( D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */, + D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */, ); path = Cells; sourceTree = ""; @@ -3890,6 +3893,7 @@ DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, + D8D688F62AB869CB000F651A /* SearchResultsProfileTableViewCell.swift in Sources */, 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */, DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift new file mode 100644 index 000000000..25af454b8 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift @@ -0,0 +1,170 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonSDK +import MastodonUI +import MetaTextKit +import MastodonLocalization +import MastodonMeta +import MastodonCore +import MastodonAsset + +class SearchResultsProfileTableViewCell: UITableViewCell { + static let reuseIdentifier = "SearchResultsProfileTableViewCell" + + private static var metricFormatter = MastodonMetricFormatter() + + private let avatarImageWrapperView: UIView + let avatarImageView: AvatarImageView + + private let metaInformationStackView: UIStackView + + private let upperLineStackView: UIStackView + let displayNameLabel: MetaLabel + let acctLabel: UILabel + + private let lowerLineStackView: UIStackView + let followersLabel: UILabel + let verifiedLinkImageView: UIImageView + let verifiedLinkLabel: MetaLabel + + private let contentStackView: UIStackView + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + + avatarImageView = AvatarImageView() + avatarImageView.cornerConfiguration = AvatarImageView.CornerConfiguration(corner: .fixed(radius: 8)) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + + avatarImageWrapperView = UIView() + avatarImageWrapperView.translatesAutoresizingMaskIntoConstraints = false + avatarImageWrapperView.addSubview(avatarImageView) + + displayNameLabel = MetaLabel(style: .statusName) + displayNameLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + displayNameLabel.setContentHuggingPriority(.required, for: .horizontal) + + acctLabel = UILabel() + acctLabel.textColor = .secondaryLabel + acctLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + acctLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + upperLineStackView = UIStackView(arrangedSubviews: [displayNameLabel, acctLabel]) + upperLineStackView.distribution = .fill + upperLineStackView.alignment = .center + + followersLabel = UILabel() + followersLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + followersLabel.textColor = .secondaryLabel + followersLabel.setContentHuggingPriority(.required, for: .horizontal) + + verifiedLinkImageView = UIImageView() + verifiedLinkImageView.setContentCompressionResistancePriority(.defaultHigh - 1, for: .vertical) + verifiedLinkImageView.setContentHuggingPriority(.required, for: .horizontal) + verifiedLinkImageView.contentMode = .scaleAspectFit + + verifiedLinkLabel = MetaLabel(style: .profileFieldValue) + verifiedLinkLabel.setContentCompressionResistancePriority(.defaultHigh - 2, for: .horizontal) + verifiedLinkLabel.translatesAutoresizingMaskIntoConstraints = false + verifiedLinkLabel.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)), + .foregroundColor: UIColor.secondaryLabel + ] + verifiedLinkLabel.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)), + .foregroundColor: Asset.Colors.Brand.blurple.color + ] + verifiedLinkLabel.isUserInteractionEnabled = false + + lowerLineStackView = UIStackView(arrangedSubviews: [followersLabel, verifiedLinkImageView, verifiedLinkLabel]) + lowerLineStackView.distribution = .fill + lowerLineStackView.alignment = .center + lowerLineStackView.spacing = 4 + lowerLineStackView.setCustomSpacing(2, after: verifiedLinkImageView) + + metaInformationStackView = UIStackView(arrangedSubviews: [upperLineStackView, lowerLineStackView]) + metaInformationStackView.axis = .vertical + metaInformationStackView.alignment = .leading + + contentStackView = UIStackView(arrangedSubviews: [avatarImageWrapperView, metaInformationStackView]) + contentStackView.translatesAutoresizingMaskIntoConstraints = false + contentStackView.axis = .horizontal + contentStackView.alignment = .center + contentStackView.spacing = 16 + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(contentStackView) + setupConstraints() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupConstraints() { + let constraints = [ + contentStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + contentStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + contentView.trailingAnchor.constraint(greaterThanOrEqualTo: contentStackView.trailingAnchor, constant: 16), + contentView.bottomAnchor.constraint(equalTo: contentStackView.bottomAnchor, constant: 8), + + upperLineStackView.trailingAnchor.constraint(greaterThanOrEqualTo: metaInformationStackView.trailingAnchor), + lowerLineStackView.trailingAnchor.constraint(greaterThanOrEqualTo: metaInformationStackView.trailingAnchor), + metaInformationStackView.trailingAnchor.constraint(greaterThanOrEqualTo: contentStackView.trailingAnchor), + + avatarImageView.widthAnchor.constraint(equalToConstant: 30), + avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor), + avatarImageView.topAnchor.constraint(greaterThanOrEqualTo: avatarImageWrapperView.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageWrapperView.leadingAnchor), + avatarImageWrapperView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + avatarImageWrapperView.bottomAnchor.constraint(greaterThanOrEqualTo: avatarImageView.bottomAnchor), + avatarImageView.centerYAnchor.constraint(equalTo: avatarImageWrapperView.centerYAnchor), + ] + + NSLayoutConstraint.activate(constraints) + } + + override func prepareForReuse() { + super.prepareForReuse() + + avatarImageView.prepareForReuse() + } + + func configure(with account: Mastodon.Entity.Account) { + let displayNameMetaContent: MetaContent + do { + let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis?.asDictionary ?? [:]) + displayNameMetaContent = try MastodonMetaContent.convert(document: content) + } catch { + displayNameMetaContent = PlaintextMetaContent(string: account.displayNameWithFallback) + } + + displayNameLabel.configure(content: displayNameMetaContent) + acctLabel.text = account.acct + followersLabel.attributedText = NSAttributedString( + format: NSAttributedString(string: L10n.Common.UserList.followersCount("%@"), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))]), + args: NSAttributedString(string: Self.metricFormatter.string(from: account.followersCount) ?? account.followersCount.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))]) + ) + + avatarImageView.setImage(url: account.avatarImageURL()) + + if let verifiedLink = account.verifiedLink?.value { + verifiedLinkImageView.image = UIImage(systemName: "checkmark") + verifiedLinkImageView.tintColor = Asset.Colors.Brand.blurple.color + + let verifiedLinkMetaContent: MetaContent + do { + let mastodonContent = MastodonContent(content: verifiedLink, emojis: [:]) + verifiedLinkMetaContent = try MastodonMetaContent.convert(document: mastodonContent) + } catch { + verifiedLinkMetaContent = PlaintextMetaContent(string: verifiedLink) + } + + verifiedLinkLabel.configure(content: verifiedLinkMetaContent) + } else { + verifiedLinkImageView.image = UIImage(systemName: "questionmark.circle") + verifiedLinkImageView.tintColor = .secondaryLabel + + verifiedLinkLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink)) + } + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift index c6d6175c7..c25bea693 100644 --- a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift @@ -28,11 +28,15 @@ class SearchResultsOverviewTableViewController: UIViewController, NeedsDependenc tableView.register(SearchResultDefaultSectionTableViewCell.self, forCellReuseIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier) tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: StatusTableViewCell.reuseIdentifier) tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: HashtagTableViewCell.reuseIdentifier) - tableView.register(UserTableViewCell.self, forCellReuseIdentifier: UserTableViewCell.reuseIdentifier) + tableView.register(SearchResultsProfileTableViewCell.self, forCellReuseIdentifier: SearchResultsProfileTableViewCell.reuseIdentifier) + + super.init(nibName: nil, bundle: nil) + + let dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemIdentifier in + + guard let self else { fatalError("Ooops, no self!?") } - let dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in switch itemIdentifier { - case .default(let item): guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() } @@ -41,54 +45,23 @@ class SearchResultsOverviewTableViewController: UIViewController, NeedsDependenc return cell case .suggestion(let suggestion): - switch suggestion { - case .hashtag(let hashtag): - - guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() } cell.configure(item: .hashtag(tag: hashtag)) return cell case .profile(let profile): - guard let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as? UserTableViewCell else { fatalError() } + guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultsProfileTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultsProfileTableViewCell else { fatalError() } - let managedObjectContext = appContext.managedObjectContext - Task { - do { - - try await managedObjectContext.perform { - guard let user = Persistence.MastodonUser.fetch(in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: authContext.mastodonAuthenticationBox.domain, - entity: profile, - cache: nil, - networkDate: Date() - )) else { return } - - cell.configure( - me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user, - tableView: tableView, - viewModel: UserTableViewCell.ViewModel( - user: user, - followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), - blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(), - followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()), - delegate: nil) - } - } catch { - // do nothing - } - } + cell.configure(with: profile) return cell } } } - super.init(nibName: nil, bundle: nil) tableView.dataSource = dataSource tableView.delegate = self self.dataSource = dataSource @@ -349,3 +322,5 @@ extension SearchResultsOverviewTableViewController: UITableViewDelegate { tableView.deselectRow(at: indexPath, animated: true) } } + +extension SearchResultsOverviewTableViewController: UserTableViewCellDelegate {} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 38bcf14fc..34fdad5ba 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -92,3 +92,10 @@ extension Mastodon.Entity.Account { return acct } } + +extension Mastodon.Entity.Account { + public var verifiedLink: Mastodon.Entity.Field? { + let firstVerified = fields?.first(where: { $0.verifiedAt != nil }) + return firstVerified + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift index 3fc92a4b5..68c7fbc95 100644 --- a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift @@ -39,8 +39,8 @@ extension FLAnimatedImageView { public func setImage( url: URL?, - placeholder: UIImage?, - scaleToSize: CGSize? + placeholder: UIImage? = nil, + scaleToSize: CGSize? = nil ) { // cancel task cancelTask()