Merge pull request #1032 from mastodon/IOS-140_Verified_Links
IOS-140: Implement Verified Links on UserView
This commit is contained in:
commit
4be4c046e8
|
@ -257,6 +257,10 @@
|
|||
"user_suspended_warning": "%s’s account has been suspended."
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_list": {
|
||||
"no_verified_link": "No verified link",
|
||||
"followers_count": "%@ followers"
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -28,5 +28,6 @@ extension DataSourceFacade {
|
|||
try await dependency.context.apiService.getBlocked(
|
||||
authenticationBox: authBox
|
||||
)
|
||||
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
|
||||
} // end func
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ extension DataSourceFacade {
|
|||
user: user,
|
||||
authenticationBox: dependency.authContext.mastodonAuthenticationBox
|
||||
)
|
||||
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
|
||||
} // end func
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -17,6 +17,7 @@ extension FollowerListViewModel {
|
|||
diffableDataSource = UserSection.diffableDataSource(
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
configuration: UserSection.Configuration(
|
||||
userTableViewCellDelegate: userTableViewCellDelegate
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ import Foundation
|
|||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import MastodonCore
|
||||
import CoreDataStack
|
||||
|
||||
extension FollowerListViewModel {
|
||||
class State: GKState {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -18,6 +18,7 @@ extension FollowingListViewModel {
|
|||
diffableDataSource = UserSection.diffableDataSource(
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
authContext: authContext,
|
||||
configuration: UserSection.Configuration(
|
||||
userTableViewCellDelegate: userTableViewCellDelegate
|
||||
)
|
||||
|
|
|
@ -40,7 +40,7 @@ final class FollowingListViewModel {
|
|||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
authContext: AuthContext,
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -165,7 +165,7 @@ extension UserListViewModel.State {
|
|||
userIDs.append(user.id)
|
||||
hasNewAppend = true
|
||||
}
|
||||
|
||||
|
||||
let maxID = response.link?.maxID
|
||||
|
||||
if hasNewAppend, maxID != nil {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -14,7 +14,9 @@ extension SearchHistoryViewModel {
|
|||
searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate
|
||||
) {
|
||||
diffableDataSource = SearchHistorySection.diffableDataSource(
|
||||
viewModel: self,
|
||||
collectionView: collectionView,
|
||||
authContext: authContext,
|
||||
context: context,
|
||||
configuration: SearchHistorySection.Configuration(
|
||||
searchHistorySectionHeaderCollectionReusableViewDelegate: searchHistorySectionHeaderCollectionReusableViewDelegate
|
||||
|
|
|
@ -12,7 +12,6 @@ import CommonOSLog
|
|||
import MastodonCore
|
||||
|
||||
final class SearchHistoryViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@ import MastodonSDK
|
|||
import MastodonCore
|
||||
|
||||
final class SearchResultViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue