diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 307d3a540..067137721 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,8 +145,10 @@ D8318A882A4468D300C0FB73 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */; }; D8318A8A2A4468DC00C0FB73 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A892A4468DC00C0FB73 /* AboutViewController.swift */; }; D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; }; + D84FA0932AE6915800987F47 /* MBProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = D84FA0922AE6915800987F47 /* MBProgressHUD */; }; D852C23C2AC5D02C00309232 /* AboutInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23B2AC5D02C00309232 /* AboutInstanceViewController.swift */; }; D852C23E2AC5D03300309232 /* InstanceRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23D2AC5D03300309232 /* InstanceRulesViewController.swift */; }; + D87364F92AE28DB500C8F919 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = D87364F82AE28DB500C8F919 /* Kanna */; }; D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; }; D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */; }; D87BFC8F291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8E291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift */; }; @@ -316,7 +318,6 @@ DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7442799056400455B82 /* HashtagTableViewCell.swift */; }; DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */; }; DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */; }; - DB63F74B279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */; }; DB63F74D27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */; }; DB63F74F2799405600455B82 /* SearchHistoryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */; }; DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */; }; @@ -1021,7 +1022,6 @@ DB63F7442799056400455B82 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = ""; }; DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Hashtag.swift"; sourceTree = ""; }; DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+DataSourceProvider.swift"; sourceTree = ""; }; - DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryUserCollectionViewCell.swift; sourceTree = ""; }; DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = ""; }; DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = ""; }; @@ -1289,6 +1289,8 @@ files = ( 357FEEAF29523D470021C9DC /* MastodonSDKDynamic in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, + D84FA0932AE6915800987F47 /* MBProgressHUD in Frameworks */, + D87364F92AE28DB500C8F919 /* Kanna in Frameworks */, 71458AF57697DB405CFEC37C /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2316,7 +2318,6 @@ isa = PBXGroup; children = ( DB5B7294273112B100081888 /* FollowingListViewController.swift */, - DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */, DB5B7297273112C800081888 /* FollowingListViewModel.swift */, DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */, DB5B729D273113F300081888 /* FollowingListViewModel+State.swift */, @@ -3120,7 +3121,6 @@ buildConfigurationList = DB427DFC25BAA00100D1B89D /* Build configuration list for PBXNativeTarget "Mastodon" */; buildPhases = ( 7A04933A2AB1D5B758D4F908 /* [CP] Check Pods Manifest.lock */, - 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB427DD025BAA00100D1B89D /* Resources */, DB427DCE25BAA00100D1B89D /* Sources */, DB427DCF25BAA00100D1B89D /* Frameworks */, @@ -3142,6 +3142,8 @@ name = Mastodon; packageProductDependencies = ( 357FEEAE29523D470021C9DC /* MastodonSDKDynamic */, + D87364F82AE28DB500C8F919 /* Kanna */, + D84FA0922AE6915800987F47 /* MBProgressHUD */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -3178,7 +3180,6 @@ DB427DEF25BAA00100D1B89D /* Sources */, DB427DF025BAA00100D1B89D /* Frameworks */, DB427DF125BAA00100D1B89D /* Resources */, - ECC2E90D421B45415C311BED /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -3326,6 +3327,8 @@ mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( 2AB501192992322500346092 /* XCRemoteSwiftPackageReference "LightChart" */, + D87364F72AE28DB500C8F919 /* XCRemoteSwiftPackageReference "Kanna" */, + D84FA0912AE6915800987F47 /* XCRemoteSwiftPackageReference "MBProgressHUD" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3419,23 +3422,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Mastodon/Pods-Mastodon-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Mastodon/Pods-Mastodon-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Mastodon/Pods-Mastodon-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 6E033728B42BA1C0018B6131 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3556,23 +3542,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - ECC2E90D421B45415C311BED /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -3997,7 +3966,6 @@ DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */, DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, - DB63F74B279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift in Sources */, DBEFCD7D282A2A3B00C0ABEA /* ReportServerRulesViewController.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, D8F917122A4C6B67008A5370 /* GeneralSettingsViewController.swift in Sources */, @@ -5463,6 +5431,22 @@ kind = branch; }; }; + D84FA0912AE6915800987F47 /* XCRemoteSwiftPackageReference "MBProgressHUD" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jdg/MBProgressHUD.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; + D87364F72AE28DB500C8F919 /* XCRemoteSwiftPackageReference "Kanna" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tid-kijyun/Kanna.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.2.7; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5499,6 +5483,16 @@ isa = XCSwiftPackageProductDependency; productName = MastodonSDKDynamic; }; + D84FA0922AE6915800987F47 /* MBProgressHUD */ = { + isa = XCSwiftPackageProductDependency; + package = D84FA0912AE6915800987F47 /* XCRemoteSwiftPackageReference "MBProgressHUD" */; + productName = MBProgressHUD; + }; + D87364F82AE28DB500C8F919 /* Kanna */ = { + isa = XCSwiftPackageProductDependency; + package = D87364F72AE28DB500C8F919 /* XCRemoteSwiftPackageReference "Kanna" */; + productName = Kanna; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DB427DCA25BAA00100D1B89D /* Project object */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6e737d55c..ec05f44da 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,6 +55,15 @@ "version": "1.6.0" } }, + { + "package": "Kanna", + "repositoryURL": "https://github.com/tid-kijyun/Kanna.git", + "state": { + "branch": null, + "revision": "f9e4922223dd0d3dfbf02ca70812cf5531fc0593", + "version": "5.2.7" + } + }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", @@ -73,6 +82,15 @@ "version": null } }, + { + "package": "MBProgressHUD", + "repositoryURL": "https://github.com/jdg/MBProgressHUD.git", + "state": { + "branch": null, + "revision": "bca42b801100b2b3a4eda0ba8dd33d858c780b0d", + "version": "1.2.0" + } + }, { "package": "MetaTextKit", "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 223ac4588..a12d3509f 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ import MastodonSDK import MastodonCore import MastodonAsset import MastodonLocalization +import MBProgressHUD final public class SceneCoordinator { @@ -28,7 +29,8 @@ final public class SceneCoordinator { private(set) weak var tabBarController: MainTabBarController! private(set) weak var splitViewController: RootSplitViewController? - + private(set) weak var rootViewController: UIViewController? + private(set) var secondaryStackHashValues = Set() var childCoordinator: Coordinator? @@ -198,7 +200,7 @@ extension SceneCoordinator { case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - + var isOnboarding: Bool { switch self { case .welcome, @@ -239,6 +241,7 @@ extension SceneCoordinator { rootViewController = splitViewController } sceneDelegate.window?.rootViewController = rootViewController // base: main + self.rootViewController = rootViewController if _authContext == nil { // entry #1: welcome DispatchQueue.main.async { @@ -465,9 +468,8 @@ private extension SceneCoordinator { _viewController.viewModel = viewModel viewController = _viewController case .following(let viewModel): - let _viewController = FollowingListViewController() - _viewController.viewModel = viewModel - viewController = _viewController + let followingListViewController = FollowingListViewController(viewModel: viewModel, coordinator: self, context: appContext) + viewController = followingListViewController case .familiarFollowers(let viewModel): let _viewController = FamiliarFollowersViewController() _viewController.viewModel = viewModel @@ -559,13 +561,30 @@ private extension SceneCoordinator { return viewController } - + private func setupDependency(for needs: NeedsDependency?) { needs?.context = appContext needs?.coordinator = self } } +//MARK: - Loading + +public extension SceneCoordinator { + func showLoading() { + guard let rootViewController else { return } + + MBProgressHUD.showAdded(to: rootViewController.view, animated: true) + } + + @MainActor + func hideLoading() { + guard let rootViewController else { return } + + MBProgressHUD.hide(for: rootViewController.view, animated: true) + } +} + //MARK: - MastodonLoginViewControllerDelegate extension SceneCoordinator: MastodonLoginViewControllerDelegate { diff --git a/Mastodon/Diffable/User/UserItem.swift b/Mastodon/Diffable/User/UserItem.swift index ff533d897..ba44aa52a 100644 --- a/Mastodon/Diffable/User/UserItem.swift +++ b/Mastodon/Diffable/User/UserItem.swift @@ -8,9 +8,11 @@ import Foundation import CoreData import CoreDataStack +import MastodonSDK enum UserItem: Hashable { case user(record: ManagedObjectRecord) + case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) case bottomLoader case bottomHeader(text: String) } diff --git a/Mastodon/Diffable/User/UserSection.swift b/Mastodon/Diffable/User/UserSection.swift index e6632c337..6997e5159 100644 --- a/Mastodon/Diffable/User/UserSection.swift +++ b/Mastodon/Diffable/User/UserSection.swift @@ -19,22 +19,37 @@ enum UserSection: Hashable { } extension UserSection { - struct Configuration { - weak var userTableViewCellDelegate: UserTableViewCellDelegate? - } - static func diffableDataSource( tableView: UITableView, context: AppContext, authContext: AuthContext, - configuration: Configuration + userTableViewCellDelegate: UserTableViewCellDelegate? ) -> UITableViewDiffableDataSource { tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self)) - return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + return UITableViewDiffableDataSource(tableView: tableView) { + tableView, + indexPath, + item -> UITableViewCell? in switch item { + case .account(let account, let relationship): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + + guard let me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { return cell } + + cell.userView.setButtonState(.loading) + cell.configure( + me: me, + tableView: tableView, + account: account, + relationship: relationship, + delegate: userTableViewCellDelegate + ) + + return cell + case .user(let record): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell context.managedObjectContext.performAndWait { @@ -50,7 +65,7 @@ extension UserSection { blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(), followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher() ), - configuration: configuration + userTableViewCellDelegate: userTableViewCellDelegate ) } @@ -60,13 +75,12 @@ extension UserSection { cell.startAnimating() return cell case .bottomHeader(let text): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineFooterTableViewCell.self), for: indexPath) as! TimelineFooterTableViewCell - cell.messageLabel.text = text - return cell - } // end switch - } // end UITableViewDiffableDataSource - } // end static func tableViewDiffableDataSource { … } - + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineFooterTableViewCell.self), for: indexPath) as! TimelineFooterTableViewCell + cell.messageLabel.text = text + return cell + } + } + } } extension UserSection { @@ -77,13 +91,13 @@ extension UserSection { tableView: UITableView, cell: UserTableViewCell, viewModel: UserTableViewCell.ViewModel, - configuration: Configuration + userTableViewCellDelegate: UserTableViewCellDelegate? ) { cell.configure( me: authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext), tableView: tableView, viewModel: viewModel, - delegate: configuration.userTableViewCellDelegate + delegate: userTableViewCellDelegate ) } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift index 3f8693476..c8f1f9405 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift @@ -8,6 +8,7 @@ import UIKit import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { static func responseToUserBlockAction( @@ -29,5 +30,26 @@ extension DataSourceFacade { authenticationBox: authBox ) dependency.context.authenticationService.fetchFollowingAndBlockedAsync() - } // end func + } + + static func responseToUserBlockAction( + dependency: NeedsDependency & AuthContextProvider, + user: Mastodon.Entity.Account + ) async throws { + let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + let apiService = dependency.context.apiService + let authBox = dependency.authContext.mastodonAuthenticationBox + + _ = try await apiService.toggleBlock( + user: user, + authenticationBox: authBox + ) + + try await dependency.context.apiService.getBlocked( + authenticationBox: authBox + ) + dependency.context.authenticationService.fetchFollowingAndBlockedAsync() + } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index 88503ae7b..6fe0005a0 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -25,7 +25,22 @@ extension DataSourceFacade { authenticationBox: dependency.authContext.mastodonAuthenticationBox ) dependency.context.authenticationService.fetchFollowingAndBlockedAsync() - } // end func + } + + static func responseToUserFollowAction( + dependency: NeedsDependency & AuthContextProvider, + user: Mastodon.Entity.Account + ) async throws { + let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() + await selectionFeedbackGenerator.selectionChanged() + + _ = try await dependency.context.apiService.toggleFollow( + user: user, + authenticationBox: dependency.authContext.mastodonAuthenticationBox + ) + dependency.context.authenticationService.fetchFollowingAndBlockedAsync() + } + } extension DataSourceFacade { diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index 8f77a1888..30c024f54 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -8,6 +8,7 @@ import UIKit import CoreDataStack import MastodonCore +import MastodonSDK extension DataSourceFacade { @@ -53,7 +54,31 @@ extension DataSourceFacade { transition: .show ) } - + + @MainActor + static func coordinateToProfileScene( + provider: ViewControllerWithDependencies & AuthContextProvider, + account: Mastodon.Entity.Account + ) async { + provider.coordinator.showLoading() + + guard let domain = account.domain else { return provider.coordinator.hideLoading() } + + Task { + do { + let user = try await provider.context.apiService.fetchUser(username: account.username, + domain: domain, + authenticationBox: provider.authContext.mastodonAuthenticationBox) + provider.coordinator.hideLoading() + + if let user { + await coordinateToProfileScene(provider: provider, user: user.asRecord) + } + } catch { + provider.coordinator.hideLoading() + } + } + } } extension DataSourceFacade { diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift index edc4fbe2f..31206c262 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift @@ -17,7 +17,8 @@ extension DataSourceFacade { item: DataSourceItem ) async { switch item { - case .status: + + case .status, .account(_, _): break // not create search history for status case .user(let record): let authenticationBox = provider.authContext.mastodonAuthenticationBox diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift index 8b1a5c84d..a90bdd0a8 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift @@ -64,16 +64,52 @@ extension DataSourceFacade { 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 - ) + static func responseToUserViewButtonAction( + dependency: NeedsDependency & AuthContextProvider, + user: Mastodon.Entity.Account, + buttonState: UserView.ButtonState + ) async throws { + switch buttonState { + case .follow: + try await DataSourceFacade.responseToUserFollowAction( + dependency: dependency, + user: user + ) + + dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.append(user.id) + + case .request: + try await DataSourceFacade.responseToUserFollowAction( + dependency: dependency, + user: user + ) + + dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.append(user.id) + case .unfollow: + try await DataSourceFacade.responseToUserFollowAction( + dependency: dependency, + user: user + ) + + dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.removeAll(where: { $0 == user.id }) + case .blocked: + try await DataSourceFacade.responseToUserBlockAction( + dependency: dependency, + user: user + ) + + dependency.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds.append(user.id) + + case .pending: + try await DataSourceFacade.responseToUserFollowAction( + dependency: dependency, + user: user + ) + + dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.removeAll(where: { $0 == user.id }) + case .none, .loading: + break //no-op } } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index 611223df8..0944cee6c 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -14,12 +14,15 @@ import MastodonLocalization extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider { func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) Task { let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath) guard let item = await item(from: source) else { return } switch item { + case .account(let account, relationship: _): + await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) case .status(let status): await DataSourceFacade.coordinateToStatusThreadScene( provider: self, diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift index 6df47ccae..b92aadcef 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -16,6 +16,7 @@ enum DataSourceItem: Hashable { case user(record: ManagedObjectRecord) case hashtag(tag: TagKind) case notification(record: ManagedObjectRecord) + case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) } extension DataSourceItem { diff --git a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewModel+Diffable.swift b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewModel+Diffable.swift index 0b0428c67..291a6b3ff 100644 --- a/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/FamiliarFollowers/FamiliarFollowersViewModel+Diffable.swift @@ -17,9 +17,7 @@ extension FamiliarFollowersViewModel { tableView: tableView, context: context, authContext: authContext, - configuration: UserSection.Configuration( - userTableViewCellDelegate: userTableViewCellDelegate - ) + userTableViewCellDelegate: userTableViewCellDelegate ) userFetchedResultsController.$records diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift index 11276c04f..d0676dc59 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift @@ -18,9 +18,7 @@ extension FollowerListViewModel { tableView: tableView, context: context, authContext: authContext, - configuration: UserSection.Configuration( - userTableViewCellDelegate: userTableViewCellDelegate - ) + userTableViewCellDelegate: userTableViewCellDelegate ) // workaround to append loader wrong animation issue diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift deleted file mode 100644 index 3ea2a74c1..000000000 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController+DataSourceProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// FollowingListViewController+DataSourceProvider.swift -// Mastodon -// -// Created by MainasuK on 2022-1-20. -// - -import UIKit - -extension FollowingListViewController: DataSourceProvider { - func item(from source: DataSourceItem.Source) async -> DataSourceItem? { - var _indexPath = source.indexPath - if _indexPath == nil, let cell = source.tableViewCell { - _indexPath = await self.indexPath(for: cell) - } - guard let indexPath = _indexPath else { return nil } - - guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { - return nil - } - - switch item { - case .user(let record): - return .user(record: record) - default: - return nil - } - } - - @MainActor - private func indexPath(for cell: UITableViewCell) async -> IndexPath? { - return tableView.indexPath(for: cell) - } -} diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index b7fe7e0a2..ecd26fb34 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -15,51 +15,64 @@ import CoreDataStack final class FollowingListViewController: UIViewController, NeedsDependency { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + weak var context: AppContext! + weak var coordinator: SceneCoordinator! var disposeBag = Set() - var viewModel: FollowingListViewModel! - - lazy var tableView: UITableView = { - let tableView = UITableView() + var viewModel: FollowingListViewModel + + let refreshControl: UIRefreshControl + let tableView: UITableView + + init(viewModel: FollowingListViewModel, coordinator: SceneCoordinator, context: AppContext) { + + self.context = context + self.coordinator = coordinator + self.viewModel = viewModel + + tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self)) - tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear - return tableView - }() - - -} -extension FollowingListViewController { - - override func viewDidLoad() { - super.viewDidLoad() - + refreshControl = UIRefreshControl() + tableView.refreshControl = refreshControl + + super.init(nibName: nil, bundle: nil) + title = L10n.Scene.Following.title - + view.backgroundColor = .secondarySystemBackground - - tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) tableView.pinToParent() - tableView.delegate = self + tableView.refreshControl?.addTarget(self, action: #selector(FollowingListViewController.refresh(_:)), for: .valueChanged) + + viewModel.tableView = tableView + + refreshControl.addTarget(self, action: #selector(FollowingListViewController.refresh(_:)), for: .valueChanged) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.setupDiffableDataSource( tableView: tableView, userTableViewCellDelegate: self ) - + // setup batch fetch - viewModel.listBatchFetchViewModel.setup(scrollView: tableView) - viewModel.listBatchFetchViewModel.shouldFetch + viewModel.shouldFetch .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + self.viewModel.stateMachine.enter(FollowingListViewModel.State.Loading.self) } .store(in: &disposeBag) @@ -75,6 +88,8 @@ extension FollowingListViewController { self.viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self) } .store(in: &disposeBag) + + tableView.refreshControl = UIRefreshControl() } override func viewWillAppear(_ animated: Bool) { @@ -82,7 +97,13 @@ extension FollowingListViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } - + + //MARK: - Actions + + @objc + func refresh(_ sender: UIRefreshControl) { + viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self) + } } // MARK: - AuthContextProvider @@ -105,3 +126,54 @@ extension FollowingListViewController: UITableViewDelegate, AutoGenerateTableVie // MARK: - UserTableViewCellDelegate extension FollowingListViewController: UserTableViewCellDelegate {} + + +// MARK: - DataSourceProvider +extension FollowingListViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .account(let account, let relationship): + return .account(account: account, relationship: relationship) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} + +//MARK: - UIScrollViewDelegate + +extension FollowingListViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + if scrollView.isDragging || scrollView.isTracking { return } + + let frame = scrollView.frame + let contentOffset = scrollView.contentOffset + let contentSize = scrollView.contentSize + + let visibleBottomY = contentOffset.y + frame.height + let offset = 2 * frame.height + let fetchThrottleOffsetY = contentSize.height - offset + + if visibleBottomY > fetchThrottleOffsetY { + viewModel.shouldFetch.send() + } + + } +} + diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift index 785937335..533360520 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+Diffable.swift @@ -9,6 +9,7 @@ import UIKit import MastodonAsset import MastodonCore import MastodonLocalization +import MastodonSDK extension FollowingListViewModel { func setupDiffableDataSource( @@ -19,9 +20,7 @@ extension FollowingListViewModel { tableView: tableView, context: context, authContext: authContext, - configuration: UserSection.Configuration( - userTableViewCellDelegate: userTableViewCellDelegate - ) + userTableViewCellDelegate: userTableViewCellDelegate ) // workaround to append loader wrong animation issue @@ -31,30 +30,39 @@ extension FollowingListViewModel { snapshot.appendItems([.bottomLoader], toSection: .main) diffableDataSource?.applySnapshotUsingReloadData(snapshot) - userFetchedResultsController.$records + $accounts .receive(on: DispatchQueue.main) - .sink { [weak self] records in - guard let self = self else { return } + .sink { [weak self] accounts in + guard let self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - let items = records.map { UserItem.user(record: $0) } + + let accountsWithRelationship: [(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)] = accounts.compactMap { account in + guard let relationship = self.relationships.first(where: {$0.id == account.id }) else { return (account: account, relationship: nil)} + + return (account: account, relationship: relationship) + } + + let items = accountsWithRelationship.map { UserItem.account(account: $0.account, relationship: $0.relationship) } snapshot.appendItems(items, toSection: .main) if let currentState = self.stateMachine.currentState { switch currentState { - case is State.Idle, is State.Loading, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - case is State.NoMore: - guard let userID = self.userID, - userID != self.authContext.mastodonAuthenticationBox.userID - else { break } - // display footer exclude self - let text = L10n.Scene.Following.footer - snapshot.appendItems([.bottomHeader(text: text)], toSection: .main) - default: - break + case is State.Loading: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + guard let userID = self.userID, + userID != self.authContext.mastodonAuthenticationBox.userID + else { break } + // display footer exclude self + let text = L10n.Scene.Following.footer + snapshot.appendItems([.bottomHeader(text: text)], toSection: .main) + case is State.Idle, is State.Fail: + break + default: + break } } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift index df71c0d7c..ac5913ab1 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift @@ -11,7 +11,7 @@ import MastodonSDK extension FollowingListViewModel { class State: GKState { - + let id = UUID() weak var viewModel: FollowingListViewModel? @@ -32,10 +32,10 @@ extension FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } switch stateClass { - case is Reloading.Type: - return viewModel.userID != nil - default: - return false + case is Reloading.Type: + return viewModel.userID != nil + default: + return false } } } @@ -43,20 +43,21 @@ extension FollowingListViewModel.State { class Reloading: FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Loading.Type: - return true - default: - return false + case is Loading.Type: + return true + default: + return false } } override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel, let stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs = [] - + viewModel.accounts = [] + viewModel.relationships = [] + stateMachine.enter(Loading.self) } } @@ -65,10 +66,10 @@ extension FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Loading.Type: - return true - default: - return false + case is Loading.Type: + return true + default: + return false } } @@ -85,12 +86,18 @@ extension FollowingListViewModel.State { class Idle: FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type, is Loading.Type: - return true - default: - return false + case is Reloading.Type, is Loading.Type: + return true + default: + return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + viewModel?.tableView?.refreshControl?.endRefreshing() + } } class Loading: FollowingListViewModel.State { @@ -99,14 +106,14 @@ extension FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Fail.Type: - return true - case is Idle.Type: - return true - case is NoMore.Type: - return true - default: - return false + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false } } @@ -117,58 +124,80 @@ extension FollowingListViewModel.State { maxID = nil } - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel, let stateMachine else { return } - guard let userID = viewModel.userID, !userID.isEmpty else { + guard let userID = viewModel.userID, userID.isEmpty == false else { stateMachine.enter(Fail.self) return } Task { do { - let response = try await viewModel.context.apiService.following( + let accountResponse = try await viewModel.context.apiService.following( userID: userID, maxID: maxID, authenticationBox: viewModel.authContext.mastodonAuthenticationBox ) + if accountResponse.value.isEmpty { + await enter(state: NoMore.self) + + viewModel.accounts = [] + viewModel.relationships = [] + return + } + var hasNewAppend = false - var userIDs = viewModel.userFetchedResultsController.userIDs - for user in response.value { - guard !userIDs.contains(user.id) else { continue } - userIDs.append(user.id) + + let newRelationships = try await viewModel.context.apiService.relationship(forAccounts: accountResponse.value, authenticationBox: viewModel.authContext.mastodonAuthenticationBox) + + var accounts = viewModel.accounts + + for user in accountResponse.value { + guard accounts.contains(user) == false else { continue } + accounts.append(user) hasNewAppend = true } - - let maxID = response.link?.maxID - + + var relationships = viewModel.relationships + + for relationship in newRelationships.value { + guard relationships.contains(relationship) == false else { continue } + relationships.append(relationship) + } + + let maxID = accountResponse.link?.maxID + if hasNewAppend, maxID != nil { await enter(state: Idle.self) } else { await enter(state: NoMore.self) } - self.maxID = maxID - viewModel.userFetchedResultsController.userIDs = userIDs + viewModel.accounts = accounts + viewModel.relationships = relationships + self.maxID = maxID } catch { await enter(state: Fail.self) } - } // end Task - } // end func didEnter + } + } } class NoMore: FollowingListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type: - return true - default: - return false + case is Reloading.Type: + return true + default: + return false } } - + override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) + + viewModel?.tableView?.refreshControl?.endRefreshing() } } } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift index e8758e645..247b4fc64 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel.swift @@ -7,8 +7,6 @@ import Foundation import Combine -import CoreData -import CoreDataStack import GameplayKit import MastodonCore import MastodonSDK @@ -20,12 +18,16 @@ final class FollowingListViewModel { // input let context: AppContext let authContext: AuthContext - let userFetchedResultsController: UserFetchedResultsController - let listBatchFetchViewModel = ListBatchFetchViewModel() - + @Published var accounts: [Mastodon.Entity.Account] + @Published var relationships: [Mastodon.Entity.Relationship] + @Published var domain: String? @Published var userID: String? - + + let shouldFetch = PassthroughSubject() + + var tableView: UITableView? + // output var diffableDataSource: UITableViewDiffableDataSource? private(set) lazy var stateMachine: GKStateMachine = { @@ -49,14 +51,9 @@ final class FollowingListViewModel { ) { self.context = context self.authContext = authContext - self.userFetchedResultsController = UserFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: domain, - additionalPredicate: nil - ) self.domain = domain self.userID = userID - // super.init() - + self.accounts = [] + self.relationships = [] } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 1edb7e5f7..a1c5e3925 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -377,12 +377,12 @@ extension ProfileViewController { profileHeaderViewController.profileHeaderView.viewModel.viewDidAppear ) .sink { [weak self] (user, _) in - guard let self = self, let user = user else { return } + guard let self, let user else { return } Task { - _ = try await self.context.apiService.accountInfo( - domain: user.domain, - userID: user.id, - authorization: self.authContext.mastodonAuthenticationBox.userAuthorization + _ = try await self.context.apiService.fetchUser( + username: user.username, + domain: user.domainFromAcct, + authenticationBox: self.authContext.mastodonAuthenticationBox ) } } diff --git a/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift index bbc46063a..d4830affc 100644 --- a/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift @@ -20,9 +20,7 @@ extension UserListViewModel { tableView: tableView, context: context, authContext: authContext, - configuration: UserSection.Configuration( - userTableViewCellDelegate: userTableViewCellDelegate - ) + userTableViewCellDelegate: userTableViewCellDelegate ) // workaround to append loader wrong animation issue diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index f2e8c7c6e..5e9d9e2db 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -52,6 +52,9 @@ extension SearchResultViewController { ) switch item { + case .account(account: _, relationship: _): + // do nothing + break case .status(let status): await DataSourceFacade.coordinateToStatusThreadScene( provider: self, diff --git a/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift index 6deeb0a2a..e3f91f462 100644 --- a/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift @@ -13,11 +13,15 @@ import MastodonLocalization import MastodonMeta import MastodonCore import Meta +import MastodonSDK +import MastodonAsset extension UserView { public func configure(user: MastodonUser, delegate: UserViewDelegate?) { self.delegate = delegate viewModel.user = user + viewModel.account = nil + viewModel.relationship = nil Publishers.CombineLatest( user.publisher(for: \.avatar), @@ -63,4 +67,55 @@ extension UserView { .assign(to: \.authorVerifiedLink, on: viewModel) .store(in: &disposeBag) } + + func configure(with account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, delegate: UserViewDelegate?) { + viewModel.account = account + viewModel.relationship = relationship + viewModel.user = nil + self.delegate = delegate + + let authorUsername = PlaintextMetaContent(string: "@\(account.username)") + authorUsernameLabel.configure(content: authorUsername) + + do { + let emojis = account.emojis?.asDictionary ?? [:] + let content = MastodonContent(content: account.displayNameWithFallback, emojis: emojis) + let metaContent = try MastodonMetaContent.convert(document: content) + authorNameLabel.configure(content: metaContent) + } catch { + let metaContent = PlaintextMetaContent(string: account.displayNameWithFallback) + authorNameLabel.configure(content: metaContent) + } + + if let imageURL = account.avatarImageURL() { + avatarButton.avatarImageView.af.setImage(withURL: imageURL) + } + + let count = account.followersCount + 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: UserView.metricFormatter.string(from: count) ?? count.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))]) + ) + + if let verifiedLinkField = account.verifiedLink { + let link = verifiedLinkField.value + + authorVerifiedImageView.image = UIImage(systemName: "checkmark") + authorVerifiedImageView.tintColor = Asset.Colors.Brand.blurple.color + authorVerifiedLabel.textColor = Asset.Colors.Brand.blurple.color + do { + let mastodonContent = MastodonContent(content: link, emojis: [:]) + let content = try MastodonMetaContent.convert(document: mastodonContent) + authorVerifiedLabel.configure(content: content) + } catch { + let content = PlaintextMetaContent(string: link) + authorVerifiedLabel.configure(content: content) + } + } else { + authorVerifiedImageView.image = UIImage(systemName: "questionmark.circle") + authorVerifiedImageView.tintColor = .secondaryLabel + authorVerifiedLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink)) + authorVerifiedLabel.textColor = .secondaryLabel + } + } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift index 526e74ab3..6b8613292 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift @@ -9,6 +9,8 @@ import UIKit import CoreDataStack import MastodonUI import Combine +import MastodonCore +import MastodonSDK extension UserTableViewCell { final class ViewModel { @@ -29,6 +31,21 @@ extension UserTableViewCell { extension UserTableViewCell { + func configure( + me: MastodonUser, + tableView: UITableView, + account: Mastodon.Entity.Account, + relationship: Mastodon.Entity.Relationship?, + delegate: UserTableViewCellDelegate? + ) { + userView.configure(with: account, relationship: relationship, delegate: delegate) + + let isMe = account.id == me.id + userView.updateButtonState(with: relationship, isMe: isMe) + + self.delegate = delegate + } + func configure( me: MastodonUser? = nil, tableView: UITableView, @@ -72,5 +89,46 @@ extension UserTableViewCell { self.delegate = delegate } - +} + +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 + ) + } + } + func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for account: Mastodon.Entity.Account, me: MastodonUser?) { + Task { + await MainActor.run { view.setButtonState(.loading) } + + try await DataSourceFacade.responseToUserViewButtonAction( + dependency: self, + user: account, + buttonState: state + ) + + // this is a dirty hack to give the backend enough time to process the relationship-change + // Otherwise the relationship might still be `pending` + try await Task.sleep(for: .seconds(1)) + + let relationship = try await self.context.apiService.relationship(forAccounts: [account], authenticationBox: authContext.mastodonAuthenticationBox).value.first + + let isMe: Bool + if let me { + isMe = account.id == me.id + } else { + isMe = false + } + + await MainActor.run { + view.viewModel.relationship = relationship + view.updateButtonState(with: relationship, isMe: isMe) + } + + } + } } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index 79715794b..83f94fd22 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -8,6 +8,7 @@ import CoreData import Foundation +/// See also `CoreDataStack.MastodonUser`, this extension contains several final public class MastodonUser: NSManagedObject { public typealias ID = String diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.colorset/Contents.json new file mode 100644 index 000000000..a36ab82ce --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.216", + "green" : "0.173", + "red" : "0.157" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.933", + "green" : "0.933", + "red" : "0.933" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json deleted file mode 100644 index 63600675a..000000000 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.dark.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.933", - "green" : "0.933", - "red" : "0.933" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.colorset/Contents.json new file mode 100644 index 000000000..2dfe8b1c4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.106", + "green" : "0.082", + "red" : "0.075" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.729", + "green" : "0.729", + "red" : "0.729" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json deleted file mode 100644 index 4e900a602..000000000 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.dark.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.729", - "green" : "0.729", - "red" : "0.729" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json deleted file mode 100644 index 6ba0d80b0..000000000 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.highlighted.light.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.106", - "green" : "0.082", - "red" : "0.075" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json deleted file mode 100644 index 70d85d5da..000000000 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/RelationshipButton/background.light.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.173", - "red" : "0.157" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index be8f204e0..9414c01e9 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -190,10 +190,8 @@ public enum Asset { public static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray") } public enum RelationshipButton { - public static let backgroundDark = ColorAsset(name: "Scene/Profile/RelationshipButton/background.dark") - public static let backgroundHighlightedDark = ColorAsset(name: "Scene/Profile/RelationshipButton/background.highlighted.dark") - public static let backgroundHighlightedLight = ColorAsset(name: "Scene/Profile/RelationshipButton/background.highlighted.light") - public static let backgroundLight = ColorAsset(name: "Scene/Profile/RelationshipButton/background.light") + public static let background = ColorAsset(name: "Scene/Profile/RelationshipButton/background") + public static let backgroundHighlighted = ColorAsset(name: "Scene/Profile/RelationshipButton/background.highlighted") } } public enum Report { diff --git a/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+History.swift deleted file mode 100644 index b116889b8..000000000 --- a/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+History.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Mastodon+Entity+History.swift -// Mastodon -// -// Created by xiaojian sun on 2021/4/2. -// - -import MastodonSDK - -extension Mastodon.Entity.History: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(uses) - hasher.combine(accounts) - hasher.combine(day) - } - - public static func == (lhs: Mastodon.Entity.History, rhs: Mastodon.Entity.History) -> Bool { - return lhs.uses == rhs.uses && lhs.uses == rhs.uses && lhs.day == rhs.day - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index 191917476..ad9561565 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -201,8 +201,9 @@ extension APIService { // user let managedObjectContext = self.backgroundManagedObjectContext + var result: MastodonUser? try await managedObjectContext.performChanges { - _ = Persistence.MastodonUser.createOrMerge( + result = Persistence.MastodonUser.createOrMerge( in: managedObjectContext, context: Persistence.MastodonUser.PersistContext( domain: domain, @@ -210,18 +211,9 @@ extension APIService { cache: nil, networkDate: response.networkDate ) - ) - } - var result: MastodonUser? - try await managedObjectContext.perform { - result = Persistence.MastodonUser.fetch(in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: response.value, - cache: nil, - networkDate: response.networkDate - )) + ).user } + return result } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index 45543dd1f..c1650e9b5 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -148,7 +148,35 @@ extension APIService { let response = try result.get() return response } - + + public func toggleBlock( + user: Mastodon.Entity.Account, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else { + throw APIError.implicit(.badRequest) + } + + let response: Mastodon.Response.Content + + if relationship.blocking { + response = try await Mastodon.API.Account.unblock( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + authorization: authenticationBox.userAuthorization + ).singleOutput() + } else { + response = try await Mastodon.API.Account.block( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + authorization: authenticationBox.userAuthorization + ).singleOutput() + } + + return response + } } extension MastodonUser { diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift index 8decfe632..e31dbedce 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift @@ -38,11 +38,11 @@ extension APIService { let _followContext: MastodonFollowContext? = try await managedObjectContext.performChanges { guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return nil } guard let user = user.object(in: managedObjectContext) else { return nil } - + let isFollowing = user.followingBy.contains(me) let isPending = user.followRequestedBy.contains(me) let needsUnfollow = isFollowing || isPending - + if needsUnfollow { // unfollow user.update(isFollowing: false, by: me) @@ -66,11 +66,11 @@ extension APIService { ) return context } - + guard let followContext = _followContext else { throw APIError.implicit(.badRequest) } - + // request follow or unfollow let result: Result, Error> do { @@ -85,13 +85,13 @@ extension APIService { } catch { result = .failure(error) } - + // update friendship state try await managedObjectContext.performChanges { guard let me = authenticationBox.authentication.user(in: managedObjectContext), let user = user.object(in: managedObjectContext) else { return } - + switch result { case .success(let response): Persistence.MastodonUser.update( @@ -108,11 +108,43 @@ extension APIService { user.update(isFollowRequested: followContext.isPending, by: me) } } - + let response = try result.get() return response } + public func toggleFollow( + user: Mastodon.Entity.Account, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + + guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else { + throw APIError.implicit(.badRequest) + } + + let response: Mastodon.Response.Content + + if relationship.following || (relationship.requested ?? false) { + // unfollow + response = try await Mastodon.API.Account.unfollow( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + authorization: authenticationBox.userAuthorization + ).singleOutput() + } else { + response = try await Mastodon.API.Account.follow( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + followQueryType: .follow(query: .init()), + authorization: authenticationBox.userAuthorization + ).singleOutput() + } + + return response + } + public func toggleShowReblogs( for user: ManagedObjectRecord, authenticationBox: MastodonAuthenticationBox diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift index 370ed4fcf..2df898977 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift @@ -18,7 +18,7 @@ extension APIService { authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { let managedObjectContext = backgroundManagedObjectContext - + let _query: Mastodon.API.Account.RelationshipQuery? = try? await managedObjectContext.perform { var ids: [MastodonUser.ID] = [] for record in records { @@ -32,14 +32,14 @@ extension APIService { guard let query = _query else { throw APIError.implicit(.badRequest) } - + let response = try await Mastodon.API.Account.relationships( session: session, domain: authenticationBox.domain, query: query, authorization: authenticationBox.userAuthorization ).singleOutput() - + try await managedObjectContext.performChanges { guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { // assertionFailure() @@ -50,7 +50,7 @@ extension APIService { for record in records { guard let user = record.object(in: managedObjectContext) else { continue } guard let relationship = relationships.first(where: { $0.id == user.id }) else { continue } - + Persistence.MastodonUser.update( mastodonUser: user, context: Persistence.MastodonUser.RelationshipContext( @@ -64,5 +64,27 @@ extension APIService { return response } - + + + public func relationship( + forAccounts accounts: [Mastodon.Entity.Account], + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { + + let ids: [MastodonUser.ID] = accounts.compactMap { $0.id } + + guard ids.isEmpty == false else { throw APIError.implicit(.badRequest) } + + let query = Mastodon.API.Account.RelationshipQuery(ids: ids) + + let response = try await Mastodon.API.Account.relationships( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + return response + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 34fdad5ba..eb2910bb9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -91,11 +91,15 @@ extension Mastodon.Entity.Account { } return acct } -} -extension Mastodon.Entity.Account { public var verifiedLink: Mastodon.Entity.Field? { let firstVerified = fields?.first(where: { $0.verifiedAt != nil }) return firstVerified } + + public var domain: String? { + guard let components = URLComponents(string: url) else { return nil } + + return components.host + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift index 180b5bd6e..f5d2200e7 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/1/29 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/relationship/) - public struct Relationship: Codable, Sendable { + public struct Relationship: Codable, Sendable, Equatable, Hashable { public typealias ID = String public let id: ID diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/FollowButton.swift b/MastodonSDK/Sources/MastodonUI/View/Content/FollowButton.swift deleted file mode 100644 index ff82aa3cb..000000000 --- a/MastodonSDK/Sources/MastodonUI/View/Content/FollowButton.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright © 2023 Mastodon gGmbH. All rights reserved. - -import UIKit -import MastodonAsset - -public final class FollowButton: RoundedEdgesButton { - - public 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) - } - } -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift index 4127ed4d4..f35afb97b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift @@ -13,6 +13,7 @@ import MastodonCore import MastodonMeta import MastodonAsset import MastodonLocalization +import MastodonSDK extension UserView { public final class ViewModel: ObservableObject { @@ -26,6 +27,8 @@ extension UserView { @Published public var authorFollowers: Int? @Published public var authorVerifiedLink: String? @Published public var user: MastodonUser? + @Published public var account: Mastodon.Entity.Account? + @Published public var relationship: Mastodon.Entity.Relationship? } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift index 9221c7f90..6ad31bc75 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift @@ -12,9 +12,11 @@ import MastodonAsset import MastodonLocalization import os import CoreDataStack +import MastodonSDK public protocol UserViewDelegate: AnyObject { func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: MastodonUser) + func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: Mastodon.Entity.Account, me: MastodonUser?) } public final class UserView: UIView { @@ -24,7 +26,8 @@ public final class UserView: UIView { } private var currentButtonState: ButtonState = .none - + public static var metricFormatter = MastodonMetricFormatter() + public weak var delegate: UserViewDelegate? public var disposeBag = Set() @@ -100,10 +103,12 @@ public final class UserView: UIView { return label }() - private let followButtonWrapper = UIView() - private let followButton: FollowButton = { - let button = FollowButton() - button.cornerRadius = 10 + public let followButtonWrapper = UIView() + public let followButton: UIButton = { + var buttonConfiguration = UIButton.Configuration.filled() + buttonConfiguration.background.cornerRadius = 10 + + let button = UIButton(configuration: buttonConfiguration) button.isHidden = true button.translatesAutoresizingMaskIntoConstraints = false button.setContentCompressionResistancePriority(.required, for: .horizontal) @@ -249,66 +254,108 @@ public extension UserView { } } - @objc private func didTapButton() { - guard let user = viewModel.user else { return } - delegate?.userView(self, didTapButtonWith: currentButtonState, for: user) + @objc private func didTapFollowButton() { + if let user = viewModel.user { + delegate?.userView(self, didTapButtonWith: currentButtonState, for: user) + } else if let account = viewModel.account { + delegate?.userView(self, didTapButtonWith: currentButtonState, for: account, me: nil) + } } - + + func updateButtonState(with relationship: Mastodon.Entity.Relationship?, isMe: Bool) { + let buttonState: UserView.ButtonState + + if let relationship { + if isMe { + buttonState = .none + } else if relationship.following { + buttonState = .unfollow + } else if relationship.blocking || (relationship.domainBlocking ?? false) { + buttonState = .blocked + } else if relationship.requested ?? false { + buttonState = .pending + } else { + buttonState = .follow + } + } else { + buttonState = .none + } + + setButtonState(buttonState) + + } + func setButtonState(_ state: ButtonState) { currentButtonState = state prepareButtonStateLayout(for: state) - + switch state { - case .loading: - followButtonWrapper.isHidden = false - followButton.isHidden = false - followButton.setTitle(nil, for: .normal) - followButton.setBackgroundColor(Asset.Colors.Button.disabled.color, for: .normal) - - case .follow: - followButtonWrapper.isHidden = false - 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 .loading: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = nil + followButton.configuration?.showsActivityIndicator = true + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userFollowing.color + followButton.configuration?.baseForegroundColor = Asset.Colors.Brand.blurple.color + followButton.isEnabled = false - case .request: - followButtonWrapper.isHidden = false - followButton.isHidden = false - followButton.setTitle(L10n.Common.Controls.Friendship.request, for: .normal) - followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal) - followButton.setTitleColor(.white, for: .normal) + case .follow: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = L10n.Common.Controls.Friendship.follow + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userFollow.color + followButton.configuration?.baseForegroundColor = .white + followButton.isEnabled = true - case .pending: - followButtonWrapper.isHidden = false - followButton.isHidden = false - followButton.setTitle(L10n.Common.Controls.Friendship.pending, for: .normal) - followButton.setTitleColor(Asset.Colors.Button.userFollowingTitle.color, for: .normal) - followButton.setBackgroundColor(Asset.Colors.Button.userFollowing.color, for: .normal) + case .request: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = L10n.Common.Controls.Friendship.request + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userFollow.color + followButton.configuration?.baseForegroundColor = .white + followButton.isEnabled = true - case .unfollow: - followButtonWrapper.isHidden = false - 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: - followButtonWrapper.isHidden = false - 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 .pending: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = L10n.Common.Controls.Friendship.pending + followButton.configuration?.baseForegroundColor = Asset.Colors.Button.userFollowingTitle.color + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userFollowing.color + followButton.isEnabled = true - case .none: - followButtonWrapper.isHidden = true - followButton.isHidden = true - followButton.setTitle(nil, for: .normal) - followButton.setBackgroundColor(.clear, for: .normal) + case .unfollow: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = L10n.Common.Controls.Friendship.following + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userFollowing.color + followButton.configuration?.baseForegroundColor = Asset.Colors.Button.userFollowingTitle.color + followButton.isEnabled = true + + case .blocked: + followButtonWrapper.isHidden = false + followButton.isHidden = false + followButton.configuration?.title = L10n.Common.Controls.Friendship.blocked + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = Asset.Colors.Button.userBlocked.color + followButton.configuration?.baseForegroundColor = .systemRed + followButton.isEnabled = true + + case .none: + followButtonWrapper.isHidden = true + followButton.isHidden = true + followButton.configuration?.title = nil + followButton.configuration?.showsActivityIndicator = false + followButton.configuration?.background.backgroundColor = .clear + followButton.isEnabled = false } - - followButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + + followButton.addTarget(self, action: #selector(didTapFollowButton), for: .touchUpInside) followButton.titleLabel?.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .boldSystemFont(ofSize: 15)) } } + diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift index bdf696e74..46ec8b5dd 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift @@ -77,15 +77,8 @@ extension ProfileRelationshipActionButton { 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) - } + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.background.color), for: .normal) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlighted.color), for: .highlighted) + setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlighted.color), for: .disabled) } } diff --git a/Podfile b/Podfile index e9538aa3d..7ecc76a7d 100644 --- a/Podfile +++ b/Podfile @@ -9,7 +9,6 @@ target 'Mastodon' do # misc pod 'SwiftGen', '~> 6.6.2' - pod 'Kanna', '~> 5.2.2' pod 'Sourcery', '~> 1.9' target 'MastodonTests' do diff --git a/Podfile.lock b/Podfile.lock index 24522c82e..59c3e48c9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,26 +1,22 @@ PODS: - - Kanna (5.2.7) - Sourcery (1.9.2): - Sourcery/CLI-Only (= 1.9.2) - Sourcery/CLI-Only (1.9.2) - SwiftGen (6.6.2) DEPENDENCIES: - - Kanna (~> 5.2.2) - Sourcery (~> 1.9) - SwiftGen (~> 6.6.2) SPEC REPOS: trunk: - - Kanna - Sourcery - SwiftGen SPEC CHECKSUMS: - Kanna: 01cfbddc127f5ff0963692f285fcbc8a9d62d234 Sourcery: 179539341c2261068528cd15a31837b7238fd901 SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c -PODFILE CHECKSUM: 597c21d7aa08efec996048577c3c4fbeffbb6305 +PODFILE CHECKSUM: ee2c03fbf7eb6e4ee75d97b1309e9ed6dfcf5cdd COCOAPODS: 1.13.0