Merge pull request #1032 from mastodon/IOS-140_Verified_Links

IOS-140: Implement Verified Links on UserView
This commit is contained in:
Nathan Mattes 2023-05-09 22:16:13 +02:00 committed by GitHub
commit 4be4c046e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 768 additions and 42 deletions

View File

@ -257,6 +257,10 @@
"user_suspended_warning": "%ss account has been suspended."
}
}
},
"user_list": {
"no_verified_link": "No verified link",
"followers_count": "%@ followers"
}
},
"scene": {

View File

@ -22,6 +22,7 @@
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
27D701F5292FC2D60031BCBB /* DataSourceFacade+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */; };
2A1BF99529F7E68400FA1BA5 /* DataSourceFacade+UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1BF99429F7E68400FA1BA5 /* DataSourceFacade+UserView.swift */; };
2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; };
2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; };
2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */; };
@ -616,6 +617,7 @@
164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; };
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+URL.swift"; sourceTree = "<group>"; };
2A1BF99429F7E68400FA1BA5 /* DataSourceFacade+UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+UserView.swift"; sourceTree = "<group>"; };
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = "<group>"; };
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = "<group>"; };
2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountHistory.swift; sourceTree = "<group>"; };
@ -2408,6 +2410,7 @@
DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */,
DB159C2A27A17BAC0068DC77 /* DataSourceFacade+Media.swift */,
2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */,
2A1BF99429F7E68400FA1BA5 /* DataSourceFacade+UserView.swift */,
DB697DD5278F4C29004EF2F7 /* DataSourceProvider.swift */,
DB697DDA278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift */,
DB023D2927A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift */,
@ -3618,6 +3621,7 @@
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB0FCB9C27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift in Sources */,
DB63F76F279A7D1100455B82 /* NotificationTableViewCell.swift in Sources */,
2A1BF99529F7E68400FA1BA5 /* DataSourceFacade+UserView.swift in Sources */,
DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */,
DBEFCD76282A143F00C0ABEA /* ReportStatusViewController.swift in Sources */,
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,

View File

@ -20,7 +20,9 @@ extension SearchHistorySection {
}
static func diffableDataSource(
viewModel: SearchHistoryViewModel,
collectionView: UICollectionView,
authContext: AuthContext,
context: AppContext,
configuration: Configuration
) -> UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
@ -28,7 +30,11 @@ extension SearchHistorySection {
let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, ManagedObjectRecord<MastodonUser>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let user = item.object(in: context.managedObjectContext) else { return }
cell.configure(viewModel: .init(value: user))
cell.configure(
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user,
viewModel: .init(value: user, followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
delegate: configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
)
}
}

View File

@ -15,6 +15,7 @@ import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonUI
import Combine
enum SearchResultSection: Hashable {
case main
@ -33,6 +34,7 @@ extension SearchResultSection {
static func tableViewDiffableDataSource(
tableView: UITableView,
context: AppContext,
authContext: AuthContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
@ -48,9 +50,10 @@ extension SearchResultSection {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: .init(value: .user(user)),
viewModel: .init(value: .user(user), followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
configuration: configuration
)
}
@ -116,12 +119,14 @@ extension SearchResultSection {
static func configure(
context: AppContext,
authContext: AuthContext,
tableView: UITableView,
cell: UserTableViewCell,
viewModel: UserTableViewCell.ViewModel,
configuration: Configuration
) {
) {
cell.configure(
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user,
tableView: tableView,
viewModel: viewModel,
delegate: configuration.userTableViewCellDelegate

View File

@ -13,6 +13,7 @@ import MastodonCore
import MastodonUI
import MastodonMeta
import MetaTextKit
import Combine
enum UserSection: Hashable {
case main
@ -29,6 +30,7 @@ extension UserSection {
static func diffableDataSource(
tableView: UITableView,
context: AppContext,
authContext: AuthContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<UserSection, UserItem> {
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
@ -42,12 +44,15 @@ extension UserSection {
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: .init(value: .user(user)),
viewModel: .init(value: .user(user), followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(), blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher()),
configuration: configuration
)
}
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
@ -66,13 +71,15 @@ extension UserSection {
extension UserSection {
static func configure(
context: AppContext,
authContext: AuthContext,
tableView: UITableView,
cell: UserTableViewCell,
viewModel: UserTableViewCell.ViewModel,
configuration: Configuration
) {
cell.configure(
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user,
tableView: tableView,
viewModel: viewModel,
delegate: configuration.userTableViewCellDelegate

View File

@ -28,5 +28,6 @@ extension DataSourceFacade {
try await dependency.context.apiService.getBlocked(
authenticationBox: authBox
)
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
} // end func
}

View File

@ -24,6 +24,7 @@ extension DataSourceFacade {
user: user,
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
} // end func
}

View File

@ -0,0 +1,58 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import MastodonUI
import CoreDataStack
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
static func responseToUserViewButtonAction(
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>,
buttonState: UserView.ButtonState
) async throws {
switch buttonState {
case .follow:
try await DataSourceFacade.responseToUserFollowAction(
dependency: dependency,
user: user
)
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.append(userObject.id)
}
case .unfollow:
try await DataSourceFacade.responseToUserFollowAction(
dependency: dependency,
user: user
)
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.removeAll(where: { $0 == userObject.id })
}
case .blocked:
try await DataSourceFacade.responseToUserBlockAction(
dependency: dependency,
user: user
)
if let userObject = user.object(in: dependency.context.managedObjectContext) {
dependency.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds.append(userObject.id)
}
case .none, .loading:
break //no-op
}
}
}
extension UserTableViewCellDelegate where Self: NeedsDependency & AuthContextProvider {
func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: MastodonUser) {
Task {
try await DataSourceFacade.responseToUserViewButtonAction(
dependency: self,
user: user.asRecord,
buttonState: state
)
}
}
}

View File

@ -10,6 +10,8 @@ import UIKit
import Combine
import MastodonCore
import MastodonLocalization
import MastodonUI
import CoreDataStack
final class FamiliarFollowersViewController: UIViewController, NeedsDependency {
@ -91,4 +93,4 @@ extension FamiliarFollowersViewController: UITableViewDelegate, AutoGenerateTabl
}
// MARK: - UserTableViewCellDelegate
extension FamiliarFollowersViewController: UserTableViewCellDelegate { }
extension FamiliarFollowersViewController: UserTableViewCellDelegate {}

View File

@ -6,6 +6,7 @@
//
import UIKit
import Combine
extension FamiliarFollowersViewModel {
func setupDiffableDataSource(
@ -15,6 +16,7 @@ extension FamiliarFollowersViewModel {
diffableDataSource = UserSection.diffableDataSource(
tableView: tableView,
context: context,
authContext: authContext,
configuration: UserSection.Configuration(
userTableViewCellDelegate: userTableViewCellDelegate
)

View File

@ -12,7 +12,6 @@ import MastodonSDK
import CoreDataStack
final class FamiliarFollowersViewModel {
var disposeBag = Set<AnyCancellable>()
// input
@ -24,7 +23,7 @@ final class FamiliarFollowersViewModel {
// output
var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>?
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext

View File

@ -12,6 +12,7 @@ import Combine
import MastodonCore
import MastodonUI
import MastodonLocalization
import CoreDataStack
final class FollowerListViewController: UIViewController, NeedsDependency {
@ -118,4 +119,4 @@ extension FollowerListViewController: UITableViewDelegate, AutoGenerateTableView
}
// MARK: - UserTableViewCellDelegate
extension FollowerListViewController: UserTableViewCellDelegate { }
extension FollowerListViewController: UserTableViewCellDelegate {}

View File

@ -17,6 +17,7 @@ extension FollowerListViewModel {
diffableDataSource = UserSection.diffableDataSource(
tableView: tableView,
context: context,
authContext: authContext,
configuration: UserSection.Configuration(
userTableViewCellDelegate: userTableViewCellDelegate
)

View File

@ -10,6 +10,7 @@ import Foundation
import GameplayKit
import MastodonSDK
import MastodonCore
import CoreDataStack
extension FollowerListViewModel {
class State: GKState {

View File

@ -14,7 +14,6 @@ import MastodonSDK
import MastodonCore
final class FollowerListViewModel {
var disposeBag = Set<AnyCancellable>()
// input
@ -25,7 +24,7 @@ final class FollowerListViewModel {
@Published var domain: String?
@Published var userID: String?
// output
var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>?
private(set) lazy var stateMachine: GKStateMachine = {

View File

@ -12,6 +12,7 @@ import Combine
import MastodonLocalization
import MastodonCore
import MastodonUI
import CoreDataStack
final class FollowingListViewController: UIViewController, NeedsDependency {
@ -116,4 +117,4 @@ extension FollowingListViewController: UITableViewDelegate, AutoGenerateTableVie
}
// MARK: - UserTableViewCellDelegate
extension FollowingListViewController: UserTableViewCellDelegate { }
extension FollowingListViewController: UserTableViewCellDelegate {}

View File

@ -18,6 +18,7 @@ extension FollowingListViewModel {
diffableDataSource = UserSection.diffableDataSource(
tableView: tableView,
context: context,
authContext: authContext,
configuration: UserSection.Configuration(
userTableViewCellDelegate: userTableViewCellDelegate
)

View File

@ -40,7 +40,7 @@ final class FollowingListViewModel {
stateMachine.enter(State.Initial.self)
return stateMachine
}()
init(
context: AppContext,
authContext: AuthContext,

View File

@ -11,6 +11,8 @@ import GameplayKit
import Combine
import MastodonCore
import MastodonLocalization
import MastodonUI
import CoreDataStack
final class FavoritedByViewController: UIViewController, NeedsDependency {
@ -107,4 +109,4 @@ extension FavoritedByViewController: UITableViewDelegate, AutoGenerateTableViewD
}
// MARK: - UserTableViewCellDelegate
extension FavoritedByViewController: UserTableViewCellDelegate { }
extension FavoritedByViewController: UserTableViewCellDelegate {}

View File

@ -11,6 +11,8 @@ import GameplayKit
import Combine
import MastodonCore
import MastodonLocalization
import MastodonUI
import CoreDataStack
final class RebloggedByViewController: UIViewController, NeedsDependency {
@ -107,4 +109,4 @@ extension RebloggedByViewController: UITableViewDelegate, AutoGenerateTableViewD
}
// MARK: - UserTableViewCellDelegate
extension RebloggedByViewController: UserTableViewCellDelegate { }
extension RebloggedByViewController: UserTableViewCellDelegate {}

View File

@ -8,6 +8,7 @@
import UIKit
import MastodonAsset
import MastodonLocalization
import Combine
extension UserListViewModel {
@MainActor
@ -18,6 +19,7 @@ extension UserListViewModel {
diffableDataSource = UserSection.diffableDataSource(
tableView: tableView,
context: context,
authContext: authContext,
configuration: UserSection.Configuration(
userTableViewCellDelegate: userTableViewCellDelegate
)

View File

@ -165,7 +165,7 @@ extension UserListViewModel.State {
userIDs.append(user.id)
hasNewAppend = true
}
let maxID = response.link?.maxID
if hasNewAppend, maxID != nil {

View File

@ -13,7 +13,6 @@ import GameplayKit
import MastodonCore
final class UserListViewModel {
let logger = Logger(subsystem: "UserListViewModel", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
@ -37,7 +36,7 @@ final class UserListViewModel {
stateMachine.enter(State.Initial.self)
return stateMachine
}()
public init(
context: AppContext,
authContext: AuthContext,
@ -53,7 +52,6 @@ final class UserListViewModel {
)
// end init
}
}
extension UserListViewModel {

View File

@ -9,8 +9,9 @@ import os.log
import UIKit
import MastodonAsset
import MastodonLocalization
import MastodonUI
protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject {
protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject, UserViewDelegate {
func searchHistorySectionHeaderCollectionReusableView(_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, clearButtonDidPressed button: UIButton)
}

View File

@ -7,21 +7,63 @@
import Foundation
import CoreDataStack
import MastodonUI
import Combine
extension SearchHistoryUserCollectionViewCell {
final class ViewModel {
let value: MastodonUser
init(value: MastodonUser) {
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
init(value: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>) {
self.value = value
self.followedUsers = followedUsers
self.blockedUsers = blockedUsers
}
}
}
extension SearchHistoryUserCollectionViewCell {
func configure(
viewModel: ViewModel
me: MastodonUser?,
viewModel: ViewModel,
delegate: UserViewDelegate?
) {
userView.configure(user: viewModel.value)
let user = viewModel.value
userView.configure(user: user, delegate: delegate)
guard let me = me else {
return userView.setButtonState(.none)
}
if user == me {
userView.setButtonState(.none)
} else {
userView.setButtonState(.loading)
}
Publishers.CombineLatest(
viewModel.followedUsers,
viewModel.blockedUsers
)
.receive(on: DispatchQueue.main)
.sink { [weak self] followed, blocked in
if blocked.contains(where: { $0 == user.id }) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(where: { $0 == user.id }) {
self?.userView.setButtonState(.unfollow)
} else {
self?.userView.setButtonState(.follow)
}
self?.setNeedsLayout()
self?.setNeedsUpdateConstraints()
self?.layoutIfNeeded()
}
.store(in: &_disposeBag)
}
}

View File

@ -10,6 +10,7 @@ import UIKit
import Combine
import CoreDataStack
import MastodonCore
import MastodonUI
final class SearchHistoryViewController: UIViewController, NeedsDependency {
@ -124,3 +125,5 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa
}
}
}
extension SearchHistoryViewController: UserTableViewCellDelegate {}

View File

@ -14,7 +14,9 @@ extension SearchHistoryViewModel {
searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate
) {
diffableDataSource = SearchHistorySection.diffableDataSource(
viewModel: self,
collectionView: collectionView,
authContext: authContext,
context: context,
configuration: SearchHistorySection.Configuration(
searchHistorySectionHeaderCollectionReusableViewDelegate: searchHistorySectionHeaderCollectionReusableViewDelegate

View File

@ -12,7 +12,6 @@ import CommonOSLog
import MastodonCore
final class SearchHistoryViewModel {
var disposeBag = Set<AnyCancellable>()
// input

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonCore
import MastodonUI
@ -55,7 +56,8 @@ extension SearchResultViewController {
// tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource(
tableView: tableView,
statusTableViewCellDelegate: self
statusTableViewCellDelegate: self,
userTableViewCellDelegate: self
)
// setup batch fetch
@ -255,3 +257,6 @@ extension SearchResultViewController: UITableViewDelegate, AutoGenerateTableView
// MARK: - StatusTableViewCellDelegate
extension SearchResultViewController: StatusTableViewCellDelegate { }
// MARK: - UserTableViewCellDelegate
extension SearchResultViewController: UserTableViewCellDelegate {}

View File

@ -12,14 +12,17 @@ extension SearchResultViewModel {
func setupDiffableDataSource(
tableView: UITableView,
statusTableViewCellDelegate: StatusTableViewCellDelegate
statusTableViewCellDelegate: StatusTableViewCellDelegate,
userTableViewCellDelegate: UserTableViewCellDelegate
) {
diffableDataSource = SearchResultSection.tableViewDiffableDataSource(
tableView: tableView,
context: context,
authContext: authContext,
configuration: .init(
authContext: authContext,
statusViewTableViewCellDelegate: statusTableViewCellDelegate
statusViewTableViewCellDelegate: statusTableViewCellDelegate,
userTableViewCellDelegate: userTableViewCellDelegate
)
)

View File

@ -15,7 +15,6 @@ import MastodonSDK
import MastodonCore
final class SearchResultViewModel {
var disposeBag = Set<AnyCancellable>()
// input

View File

@ -15,7 +15,10 @@ import MastodonCore
import Meta
extension UserView {
public func configure(user: MastodonUser) {
public func configure(user: MastodonUser, delegate: UserViewDelegate?) {
self.delegate = delegate
viewModel.user = user
Publishers.CombineLatest(
user.publisher(for: \.avatar),
UserDefaults.shared.publisher(for: \.preferredStaticAvatar)
@ -46,5 +49,18 @@ extension UserView {
.map { $0 as String? }
.assign(to: \.authorUsername, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.followersCount)
.map { Int($0) }
.assign(to: \.authorFollowers, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.fields)
.map { fields in
let firstVerified = fields.first(where: { $0.verifiedAt != nil })
return firstVerified?.value
}
.assign(to: \.authorVerifiedLink, on: viewModel)
.store(in: &disposeBag)
}
}

View File

@ -7,13 +7,20 @@
import UIKit
import CoreDataStack
import MastodonUI
import Combine
extension UserTableViewCell {
final class ViewModel {
let value: Value
init(value: Value) {
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
init(value: Value, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>) {
self.value = value
self.followedUsers = followedUsers
self.blockedUsers = blockedUsers
}
enum Value {
@ -26,13 +33,42 @@ extension UserTableViewCell {
extension UserTableViewCell {
func configure(
me: MastodonUser?,
tableView: UITableView,
viewModel: ViewModel,
delegate: UserTableViewCellDelegate?
) {
switch viewModel.value {
case .user(let user):
userView.configure(user: user)
userView.configure(user: user, delegate: delegate)
guard let me = me else {
return userView.setButtonState(.none)
}
if user == me {
userView.setButtonState(.none)
} else {
userView.setButtonState(.loading)
}
Publishers.CombineLatest(
viewModel.followedUsers,
viewModel.blockedUsers
)
.receive(on: DispatchQueue.main)
.sink { [weak self] followed, blocked in
if blocked.contains(user.id) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(user.id) {
self?.userView.setButtonState(.unfollow)
} else if user != me {
self?.userView.setButtonState(.follow)
}
}
.store(in: &disposeBag)
}
self.delegate = delegate

View File

@ -13,7 +13,7 @@ import MastodonLocalization
import MastodonUI
import MastodonSDK
protocol UserTableViewCellDelegate: AnyObject { }
protocol UserTableViewCellDelegate: UserViewDelegate, AnyObject { }
final class UserTableViewCell: UITableViewCell {
@ -23,9 +23,13 @@ final class UserTableViewCell: UITableViewCell {
let separatorLine = UIView.separatorLine
var disposeBag = Set<AnyCancellable>()
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
disposeBag = Set<AnyCancellable>()
userView.prepareForReuse()
}

View File

@ -69,7 +69,8 @@ extension SendPostIntentHandler: SendPostIntentHandling {
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: .init(accessToken: authentication.appAccessToken),
userAuthorization: .init(accessToken: authentication.userAccessToken)
userAuthorization: .init(accessToken: authentication.userAccessToken),
inMemoryCache: .sharedCache(for: authentication.objectID.description)
)
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.150",
"blue" : "0x30",
"green" : "0x3B",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.800",
"green" : "0.227",
"red" : "0.337"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.150",
"blue" : "0xFB",
"green" : "0x2C",
"red" : "0x55"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.300",
"blue" : "0xFF",
"green" : "0x64",
"red" : "0x63"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.800",
"green" : "0.227",
"red" : "0.337"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0x64",
"red" : "0x63"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -57,6 +57,10 @@ public enum Asset {
public static let inactive = ColorAsset(name: "Colors/Button/inactive")
public static let tagFollow = ColorAsset(name: "Colors/Button/tagFollow")
public static let tagUnfollow = ColorAsset(name: "Colors/Button/tagUnfollow")
public static let userBlocked = ColorAsset(name: "Colors/Button/userBlocked")
public static let userFollow = ColorAsset(name: "Colors/Button/userFollow")
public static let userFollowing = ColorAsset(name: "Colors/Button/userFollowing")
public static let userFollowingTitle = ColorAsset(name: "Colors/Button/userFollowingTitle")
}
public enum Icon {
public static let plus = ColorAsset(name: "Colors/Icon/plus")

View File

@ -15,19 +15,22 @@ public struct MastodonAuthenticationBox: UserIdentifier {
public let userID: MastodonUser.ID
public let appAuthorization: Mastodon.API.OAuth.Authorization
public let userAuthorization: Mastodon.API.OAuth.Authorization
public let inMemoryCache: MastodonAccountInMemoryCache
public init(
authenticationRecord: ManagedObjectRecord<MastodonAuthentication>,
domain: String,
userID: MastodonUser.ID,
appAuthorization: Mastodon.API.OAuth.Authorization,
userAuthorization: Mastodon.API.OAuth.Authorization
userAuthorization: Mastodon.API.OAuth.Authorization,
inMemoryCache: MastodonAccountInMemoryCache
) {
self.authenticationRecord = authenticationRecord
self.domain = domain
self.userID = userID
self.appAuthorization = appAuthorization
self.userAuthorization = userAuthorization
self.inMemoryCache = inMemoryCache
}
}
@ -39,8 +42,26 @@ extension MastodonAuthenticationBox {
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken),
inMemoryCache: .sharedCache(for: authentication.objectID.description)
)
}
}
public class MastodonAccountInMemoryCache {
@Published public var followingUserIds: [String] = []
@Published public var blockedUserIds: [String] = []
static var sharedCaches = [String: MastodonAccountInMemoryCache]()
public static func sharedCache(for key: String) -> MastodonAccountInMemoryCache {
if let sharedCache = sharedCaches[key] {
return sharedCache
}
let sharedCache = MastodonAccountInMemoryCache()
sharedCaches[key] = sharedCache
return sharedCache
}
}

View File

@ -12,6 +12,8 @@ import CoreData
import CoreDataStack
import MastodonSDK
private typealias IterativeResponse = (ids: [String], maxID: String?)
public final class AuthenticationService: NSObject {
var disposeBag = Set<AnyCancellable>()
@ -25,6 +27,57 @@ public final class AuthenticationService: NSObject {
// output
@Published public var mastodonAuthentications: [ManagedObjectRecord<MastodonAuthentication>] = []
@Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = []
private func fetchFollowedBlockedUserIds(
_ authBox: MastodonAuthenticationBox,
_ previousFollowingIDs: [String]? = nil,
_ maxID: String? = nil
) async throws {
guard let apiService = apiService else { return }
let followingResponse = try await fetchFollowing(maxID, apiService, authBox)
let followingIds = (previousFollowingIDs ?? []) + followingResponse.ids
if let nextMaxID = followingResponse.maxID {
return try await fetchFollowedBlockedUserIds(authBox, followingIds, nextMaxID)
}
let blockedIds = try await apiService.getBlocked(
authenticationBox: authBox
).value.map { $0.id }
authBox.inMemoryCache.followingUserIds = followingIds
authBox.inMemoryCache.blockedUserIds = blockedIds
}
private func fetchFollowing(
_ maxID: String?,
_ apiService: APIService,
_ mastodonAuthenticationBox: MastodonAuthenticationBox
) async throws -> IterativeResponse {
let response = try await apiService.following(
userID: mastodonAuthenticationBox.userID,
maxID: maxID,
authenticationBox: mastodonAuthenticationBox
)
let ids: [String] = response.value.map { $0.id }
let maxID: String? = response.link?.maxID
return (ids, maxID)
}
public func fetchFollowingAndBlockedAsync() {
/// We're dispatching this as a separate async call to not block the caller
/// Also we'll only be updating the current active user as the state will be reflesh upon user-change anyways
Task {
if let authBox = mastodonAuthenticationBoxes.first {
do { try await fetchFollowedBlockedUserIds(authBox) }
catch {}
}
}
}
public let updateActiveUserAccountPublisher = PassthroughSubject<Void, Never>()
init(
@ -50,6 +103,18 @@ public final class AuthenticationService: NSObject {
super.init()
mastodonAuthenticationFetchedResultsController.delegate = self
$mastodonAuthenticationBoxes
.sink { [weak self] boxes in
Task { [weak self] in
for authBox in boxes {
do { try await self?.fetchFollowedBlockedUserIds(authBox) }
catch {}
}
}
}
.store(in: &disposeBag)
// TODO: verify credentials for active authentication

View File

@ -267,7 +267,8 @@ extension NotificationService {
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: .init(accessToken: authentication.appAccessToken),
userAuthorization: .init(accessToken: authentication.userAccessToken)
userAuthorization: .init(accessToken: authentication.userAccessToken),
inMemoryCache: .sharedCache(for: authentication.objectID.description)
)
}
}

View File

@ -508,6 +508,14 @@ public enum L10n {
}
}
}
public enum UserList {
/// %@ followers
public static func followersCount(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.UserList.FollowersCount", String(describing: p1), fallback: "%@ followers")
}
/// No verified link
public static let noVerifiedLink = L10n.tr("Localizable", "Common.UserList.NoVerifiedLink", fallback: "No verified link")
}
}
public enum Extension {
public enum OpenIn {

View File

@ -181,6 +181,8 @@ Your profile looks like this to them.";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.UserList.NoVerifiedLink" = "No verified link";
"Common.UserList.FollowersCount" = "%@ followers";
"Extension.OpenIn.InvalidLinkError" = "This doesn't seem to be a valid Mastodon link.";
"Scene.AccountList.AddAccount" = "Add Account";
"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher";

View File

@ -0,0 +1,47 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
public extension NSAttributedString {
convenience init(format: NSAttributedString, args: NSAttributedString...) {
let mutableNSAttributedString = NSMutableAttributedString(attributedString: format)
zip(format.string.ranges(of: "%@"), Array(args)).forEach { range, arg in
mutableNSAttributedString.replaceCharacters(in: .init(range: range, originalText: format.string), with: arg)
}
self.init(attributedString: mutableNSAttributedString)
}
}
private extension String {
func ranges(of searchString: String) -> [Range<String.Index>] {
let indices = indices(of: searchString)
let count = searchString.count
return indices.map({ index(startIndex, offsetBy: $0)..<index(startIndex, offsetBy: $0+count) })
}
func indices(of occurrence: String) -> [Int] {
var indices = [Int]()
var position = startIndex
while let range = range(of: occurrence, range: position..<endIndex) {
let i = distance(from: startIndex, to: range.lowerBound)
indices.append(i)
let offset = occurrence.distance(from: occurrence.startIndex, to: occurrence.endIndex) - 1
guard let after = index(range.lowerBound, offsetBy: offset, limitedBy: endIndex) else {
break
}
position = index(after: after)
}
return indices
}
}
private extension NSRange {
init(range: Range<String.Index>, originalText: String) {
self.init(
location: range.lowerBound.utf16Offset(in: originalText),
length: range.upperBound.utf16Offset(in: originalText) - range.lowerBound.utf16Offset(in: originalText)
)
}
}

View File

@ -5,11 +5,15 @@
// Created by MainasuK on 2022-1-19.
//
import CoreDataStack
import os.log
import UIKit
import Combine
import MetaTextKit
import MastodonCore
import MastodonMeta
import MastodonAsset
import MastodonLocalization
extension UserView {
public final class ViewModel: ObservableObject {
@ -22,10 +26,15 @@ extension UserView {
@Published public var authorAvatarImageURL: URL?
@Published public var authorName: MetaContent?
@Published public var authorUsername: String?
@Published public var authorFollowers: Int?
@Published public var authorVerifiedLink: String?
@Published public var user: MastodonUser?
}
}
extension UserView.ViewModel {
private static var metricFormatter = MastodonMetricFormatter()
func bind(userView: UserView) {
// avatar
Publishers.CombineLatest(
@ -74,5 +83,43 @@ extension UserView.ViewModel {
}
}
.store(in: &disposeBag)
$authorFollowers
.sink { count in
guard let count = count else {
userView.authorFollowersLabel.text = nil
return
}
userView.authorFollowersLabel.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: count) ?? count.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))])
)
}
.store(in: &disposeBag)
$authorVerifiedLink
.sink { link in
userView.authorVerifiedImageView.image = link == nil ? UIImage(systemName: "questionmark.circle") : UIImage(systemName: "checkmark")
switch link {
case let .some(link):
userView.authorVerifiedImageView.tintColor = Asset.Colors.brand.color
userView.authorVerifiedLabel.textColor = Asset.Colors.brand.color
do {
let mastodonContent = MastodonContent(content: link, emojis: [:])
let content = try MastodonMetaContent.convert(document: mastodonContent)
userView.authorVerifiedLabel.configure(content: content)
} catch {
let content = PlaintextMetaContent(string: link)
userView.authorVerifiedLabel.configure(content: content)
}
case .none:
userView.authorVerifiedImageView.tintColor = .secondaryLabel
userView.authorVerifiedLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink))
userView.authorVerifiedLabel.textColor = .secondaryLabel
}
}
.store(in: &disposeBag)
}
}

View File

@ -8,9 +8,25 @@
import UIKit
import Combine
import MetaTextKit
import MastodonAsset
import MastodonLocalization
import os
import CoreDataStack
public protocol UserViewDelegate: AnyObject {
func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: MastodonUser)
}
public final class UserView: UIView {
public enum ButtonState {
case none, loading, follow, unfollow, blocked
}
private var currentButtonState: ButtonState = .none
public weak var delegate: UserViewDelegate?
public var disposeBag = Set<AnyCancellable>()
public private(set) lazy var viewModel: ViewModel = {
@ -38,6 +54,68 @@ public final class UserView: UIView {
// author username
public let authorUsernameLabel = MetaLabel(style: .statusUsername)
public let authorFollowersLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .secondaryLabel
return label
}()
public let authorVerifiedLabel: MetaLabel = {
let label = MetaLabel(style: .profileFieldValue)
label.setContentCompressionResistancePriority(.defaultHigh - 2, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
label.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)),
.foregroundColor: UIColor.secondaryLabel
]
label.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)),
.foregroundColor: Asset.Colors.brand.color
]
label.isUserInteractionEnabled = false
return label
}()
public let authorVerifiedImageView: UIImageView = {
let imageView = UIImageView()
imageView.setContentHuggingPriority(.required, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
imageView.setContentHuggingPriority(.required, for: .vertical)
imageView.contentMode = .scaleAspectFit
return imageView
}()
private let verifiedStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .center
return stackView
}()
private let verifiedStackCenterSpacerView: UILabel = {
let label = UILabel()
label.text = " · "
label.textColor = .secondaryLabel
return label
}()
private let followButton: FollowButton = {
let button = FollowButton()
button.cornerRadius = 10
button.isHidden = true
button.translatesAutoresizingMaskIntoConstraints = false
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.setContentHuggingPriority(.required, for: .horizontal)
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 96),
button.heightAnchor.constraint(equalToConstant: 36)
])
return button
}()
public func prepareForReuse() {
disposeBag.removeAll()
@ -45,6 +123,7 @@ public final class UserView: UIView {
viewModel.authorAvatarImageURL = nil
avatarButton.avatarImageView.cancelTask()
setButtonState(.none)
}
public override init(frame: CGRect) {
@ -82,11 +161,52 @@ extension UserView {
labelStackView.axis = .vertical
containerStackView.addArrangedSubview(labelStackView)
labelStackView.addArrangedSubview(authorNameLabel)
labelStackView.addArrangedSubview(authorUsernameLabel)
authorNameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
authorUsernameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
// follow button
containerStackView.addArrangedSubview(followButton)
let nameStackView = UIStackView()
nameStackView.axis = .horizontal
let nameSpacer = UIView()
nameSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
nameStackView.addArrangedSubview(authorNameLabel)
nameStackView.addArrangedSubview(authorUsernameLabel)
nameStackView.addArrangedSubview(nameSpacer)
authorNameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
authorNameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
authorUsernameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
authorUsernameLabel.setContentCompressionResistancePriority(.defaultHigh - 1, for: .horizontal)
labelStackView.addArrangedSubview(nameStackView)
let verifiedSpacerView = UIView()
let verifiedStackTrailingSpacerView = UIView()
verifiedStackTrailingSpacerView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
let verifiedContainerStack = UIStackView()
verifiedContainerStack.axis = .horizontal
verifiedContainerStack.alignment = .center
NSLayoutConstraint.activate([
authorVerifiedImageView.widthAnchor.constraint(equalToConstant: 15),
verifiedSpacerView.widthAnchor.constraint(equalToConstant: 2)
])
verifiedContainerStack.addArrangedSubview(authorVerifiedImageView)
verifiedContainerStack.addArrangedSubview(verifiedSpacerView)
verifiedContainerStack.addArrangedSubview(authorVerifiedLabel)
verifiedStackView.addArrangedSubview(authorFollowersLabel)
verifiedStackView.addArrangedSubview(verifiedStackCenterSpacerView)
verifiedStackView.addArrangedSubview(verifiedContainerStack)
verifiedStackView.addArrangedSubview(verifiedStackTrailingSpacerView)
labelStackView.addArrangedSubview(verifiedStackView)
avatarButton.isUserInteractionEnabled = false
authorNameLabel.isUserInteractionEnabled = false
authorUsernameLabel.isUserInteractionEnabled = false
@ -95,3 +215,90 @@ extension UserView {
}
}
private final class FollowButton: RoundedEdgesButton {
init() {
super.init(frame: .zero)
configureAppearance()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureAppearance() {
setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
setTitleColor(Asset.Colors.Label.primaryReverse.color.withAlphaComponent(0.5), for: .highlighted)
switch traitCollection.userInterfaceStyle {
case .dark:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundDark.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedDark.color), for: .disabled)
default:
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundLight.color), for: .normal)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .highlighted)
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .disabled)
}
}
}
public extension UserView {
private func prepareButtonStateLayout(for state: ButtonState) {
switch state {
case .none:
verifiedStackView.axis = .horizontal
verifiedStackView.alignment = .leading
verifiedStackCenterSpacerView.isHidden = false
followButton.isHidden = true
default:
verifiedStackView.axis = .vertical
verifiedStackView.alignment = .leading
verifiedStackCenterSpacerView.isHidden = true
followButton.isHidden = false
}
}
@objc private func didTapButton() {
guard let user = viewModel.user else { return }
delegate?.userView(self, didTapButtonWith: currentButtonState, for: user)
}
func setButtonState(_ state: ButtonState) {
currentButtonState = state
prepareButtonStateLayout(for: state)
switch state {
case .loading:
followButton.isHidden = false
followButton.setTitle(nil, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.disabled.color, for: .normal)
case .follow:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.follow, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followButton.setTitleColor(.white, for: .normal)
case .unfollow:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.following, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollowing.color, for: .normal)
followButton.setTitleColor(Asset.Colors.Button.userFollowingTitle.color, for: .normal)
case .blocked:
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.blocked, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userBlocked.color, for: .normal)
followButton.setTitleColor(.systemRed, for: .normal)
case .none:
followButton.isHidden = true
followButton.setTitle(nil, for: .normal)
followButton.setBackgroundColor(.clear, for: .normal)
}
followButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
followButton.titleLabel?.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .boldSystemFont(ofSize: 15))
}
}