Merge pull request #1136 from mastodon/remove_coredata/following

Don't persist following
This commit is contained in:
Nathan Mattes 2023-11-09 11:01:18 +01:00 committed by GitHub
commit 7004a22e72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 853 additions and 461 deletions

View File

@ -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 = "<group>"; };
DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Hashtag.swift"; sourceTree = "<group>"; };
DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowerListViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
DB63F74A279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryUserCollectionViewCell.swift; sourceTree = "<group>"; };
DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
@ -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 */;

View File

@ -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",

View File

@ -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<Int>()
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 {

View File

@ -8,9 +8,11 @@
import Foundation
import CoreData
import CoreDataStack
import MastodonSDK
enum UserItem: Hashable {
case user(record: ManagedObjectRecord<MastodonUser>)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
case bottomLoader
case bottomHeader(text: String)
}

View File

@ -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<UserSection, UserItem> {
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
)
}

View File

@ -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()
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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,

View File

@ -16,6 +16,7 @@ enum DataSourceItem: Hashable {
case user(record: ManagedObjectRecord<MastodonUser>)
case hashtag(tag: TagKind)
case notification(record: ManagedObjectRecord<Notification>)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
}
extension DataSourceItem {

View File

@ -17,9 +17,7 @@ extension FamiliarFollowersViewModel {
tableView: tableView,
context: context,
authContext: authContext,
configuration: UserSection.Configuration(
userTableViewCellDelegate: userTableViewCellDelegate
)
userTableViewCellDelegate: userTableViewCellDelegate
)
userFetchedResultsController.$records

View File

@ -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

View File

@ -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)
}
}

View File

@ -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<AnyCancellable>()
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()
}
}
}

View File

@ -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<UserSection, UserItem>()
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
}
}

View File

@ -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()
}
}
}

View File

@ -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<Void, Never>()
var tableView: UITableView?
// output
var diffableDataSource: UITableViewDiffableDataSource<UserSection, UserItem>?
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 = []
}
}

View File

@ -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
)
}
}

View File

@ -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

View File

@ -52,6 +52,9 @@ extension SearchResultViewController {
)
switch item {
case .account(account: _, relationship: _):
// do nothing
break
case .status(let status):
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,

View File

@ -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
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<Mastodon.Entity.Relationship> {
guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else {
throw APIError.implicit(.badRequest)
}
let response: Mastodon.Response.Content<Mastodon.Entity.Relationship>
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 {

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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<Mastodon.Entity.Relationship> {
guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else {
throw APIError.implicit(.badRequest)
}
let response: Mastodon.Response.Content<Mastodon.Entity.Relationship>
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<MastodonUser>,
authenticationBox: MastodonAuthenticationBox

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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?
}
}

View File

@ -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<AnyCancellable>()
@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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