diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 067137721..555c9747e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -317,7 +317,6 @@ DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */; }; 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 */; }; 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 +1020,6 @@ DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -2494,7 +2492,6 @@ isa = PBXGroup; children = ( DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */, - DB63F7482799126300455B82 /* FollowerListViewController+DataSourceProvider.swift */, DB6B74F1272FB67600C70B6E /* FollowerListViewModel.swift */, DB6B74F3272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift */, DB6B74F5272FBCDB00C70B6E /* FollowerListViewModel+State.swift */, @@ -3738,7 +3735,6 @@ DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */, - DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index a12d3509f..d98e27b25 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -222,43 +222,35 @@ extension SceneCoordinator { func setup() { let rootViewController: UIViewController - - do { - let _authentication = AuthenticationServiceProvider.shared.authenticationSortedByActivation().first - let _authContext = _authentication.flatMap { AuthContext(authentication: $0) } - self.authContext = _authContext - - switch UIDevice.current.userInterfaceIdiom { - case .phone: - let viewController = MainTabBarController(context: appContext, coordinator: self, authContext: _authContext) - self.splitViewController = nil - self.tabBarController = viewController - rootViewController = viewController - default: - let splitViewController = RootSplitViewController(context: appContext, coordinator: self, authContext: _authContext) - self.splitViewController = splitViewController - self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController - rootViewController = splitViewController - } - sceneDelegate.window?.rootViewController = rootViewController // base: main - self.rootViewController = rootViewController - if _authContext == nil { // entry #1: welcome - DispatchQueue.main.async { - _ = self.present( - scene: .welcome, - from: self.sceneDelegate.window?.rootViewController, - transition: .modal(animated: true, completion: nil) - ) - } + let _authentication = AuthenticationServiceProvider.shared.authenticationSortedByActivation().first + let _authContext = _authentication.flatMap { AuthContext(authentication: $0) } + self.authContext = _authContext + + switch UIDevice.current.userInterfaceIdiom { + case .phone: + let viewController = MainTabBarController(context: appContext, coordinator: self, authContext: _authContext) + self.splitViewController = nil + self.tabBarController = viewController + rootViewController = viewController + default: + let splitViewController = RootSplitViewController(context: appContext, coordinator: self, authContext: _authContext) + self.splitViewController = splitViewController + self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController + rootViewController = splitViewController + } + + sceneDelegate.window?.rootViewController = rootViewController // base: main + self.rootViewController = rootViewController + + if _authContext == nil { // entry #1: welcome + DispatchQueue.main.async { + _ = self.present( + scene: .welcome, + from: self.sceneDelegate.window?.rootViewController, + transition: .modal(animated: true, completion: nil) + ) } - - } catch { - assertionFailure(error.localizedDescription) - Task { - try? await Task.sleep(nanoseconds: .second * 2) - setup() // entry #2: retry - } // end Task } } @@ -464,9 +456,8 @@ private extension SceneCoordinator { _viewController.viewModel = viewModel viewController = _viewController case .follower(let viewModel): - let _viewController = FollowerListViewController() - _viewController.viewModel = viewModel - viewController = _viewController + let followerListViewController = FollowerListViewController(viewModel: viewModel, coordinator: self, context: appContext) + viewController = followerListViewController case .following(let viewModel): let followingListViewController = FollowingListViewController(viewModel: viewModel, coordinator: self, context: appContext) viewController = followingListViewController diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift index 912540f69..7560d9008 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Translate.swift @@ -49,11 +49,12 @@ private extension DataSourceFacade { authenticationBox: provider.authContext.mastodonAuthenticationBox ).value - guard let content = value.content else { + if value.content != nil { + return value + } else { return nil } - - return value + } catch { throw TranslationFailure.emptyOrInvalidResponse } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index c327357bc..864b0355e 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -150,7 +150,7 @@ extension MastodonRegisterViewController { return "en" } let fallbackLanguageCode: String = { - let code = Locale.current.languageCode ?? "en" + let code = Locale.current.language.languageCode?.identifier ?? "en" guard localCode[code] != nil else { return "en" } return code }() @@ -161,7 +161,7 @@ extension MastodonRegisterViewController { } // prepare languageCode and validate then return fallback if needs let local = Locale(identifier: identifier) - guard let languageCode = local.languageCode, + guard let languageCode = local.language.languageCode?.identifier, localCode[languageCode] != nil else { return fallbackLanguageCode @@ -170,10 +170,10 @@ extension MastodonRegisterViewController { let extendCodes: [String] = { let locales = Locale.preferredLanguages.map { Locale(identifier: $0) } return locales.compactMap { locale in - guard let languageCode = locale.languageCode, - let regionCode = locale.regionCode + guard let languageCode = locale.language.languageCode?.identifier, + let regionIdentifier = locale.region?.identifier else { return nil } - return languageCode + "-" + regionCode + return languageCode + "-" + regionIdentifier } }() let _firstMatchExtendCode = extendCodes.first { code in diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift deleted file mode 100644 index 956cb0704..000000000 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController+DataSourceProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// FollowerListViewController+DataSourceProvider.swift -// Mastodon -// -// Created by MainasuK on 2022-1-20. -// - -import UIKit - -extension FollowerListViewController: 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/Follower/FollowerListViewController.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift index 733bd1699..daa2838a5 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewController.swift @@ -11,28 +11,53 @@ import Combine import MastodonCore import MastodonUI import MastodonLocalization -import CoreDataStack final class FollowerListViewController: 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: FollowerListViewModel! + var viewModel: FollowerListViewModel - lazy var tableView: UITableView = { - let tableView = UITableView() + let tableView: UITableView + let refreshControl: UIRefreshControl + + init(viewModel: FollowerListViewModel, 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 - }() - - + + refreshControl = UIRefreshControl() + tableView.refreshControl = refreshControl + + super.init(nibName: nil, bundle: nil) + + title = L10n.Scene.Follower.title + + view.backgroundColor = .secondarySystemBackground + + view.addSubview(tableView) + tableView.pinToParent() + tableView.delegate = self + tableView.refreshControl?.addTarget(self, action: #selector(FollowerListViewController.refresh(_:)), for: .valueChanged) + + viewModel.tableView = tableView + + refreshControl.addTarget(self, action: #selector(FollowerListViewController.refresh(_:)), for: .valueChanged) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension FollowerListViewController { @@ -40,23 +65,13 @@ extension FollowerListViewController { override func viewDidLoad() { super.viewDidLoad() - title = L10n.Scene.Follower.title - - view.backgroundColor = .secondarySystemBackground - - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) - tableView.pinToParent() - - tableView.delegate = self 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 } @@ -75,6 +90,8 @@ extension FollowerListViewController { self.viewModel.stateMachine.enter(FollowerListViewModel.State.Reloading.self) } .store(in: &disposeBag) + + tableView.refreshControl = UIRefreshControl() } override func viewWillAppear(_ animated: Bool) { @@ -82,7 +99,13 @@ extension FollowerListViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } - + + //MARK: - Actions + + @objc + func refresh(_ sender: UIRefreshControl) { + viewModel.stateMachine.enter(FollowerListViewModel.State.Reloading.self) + } } // MARK: - AuthContextProvider @@ -107,3 +130,52 @@ extension FollowerListViewController: UITableViewDelegate, AutoGenerateTableView // MARK: - UserTableViewCellDelegate extension FollowerListViewController: UserTableViewCellDelegate {} + + +// MARK: - DataSourceProvider +extension FollowerListViewController: 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 FollowerListViewController: 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/Follower/FollowerListViewModel+Diffable.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift index d0676dc59..d3384e538 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+Diffable.swift @@ -8,6 +8,7 @@ import UIKit import MastodonAsset import MastodonLocalization +import MastodonSDK extension FollowerListViewModel { func setupDiffableDataSource( @@ -27,34 +28,43 @@ extension FollowerListViewModel { snapshot.appendSections([.main]) snapshot.appendItems([.bottomLoader], toSection: .main) diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil) - - 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 hint footer exclude self - let text = L10n.Scene.Follower.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 } } - + diffableDataSource.apply(snapshot, animatingDifferences: false) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift index fb7b20166..e25ff867f 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift @@ -9,7 +9,6 @@ import Foundation import GameplayKit import MastodonSDK import MastodonCore -import CoreDataStack extension FollowerListViewModel { class State: GKState { @@ -61,8 +60,9 @@ extension FollowerListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.userFetchedResultsController.userIDs = [] - + viewModel.accounts = [] + viewModel.relationships = [] + stateMachine.enter(Loading.self) } } @@ -97,6 +97,12 @@ extension FollowerListViewModel.State { return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + viewModel?.tableView?.refreshControl?.endRefreshing() + } } class Loading: FollowerListViewModel.State { @@ -123,47 +129,66 @@ extension FollowerListViewModel.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.followers( + let accountResponse = try await viewModel.context.apiService.followers( 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 - - if hasNewAppend && maxID != nil { + + 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) } - + + viewModel.accounts = accounts + viewModel.relationships = relationships self.maxID = maxID - viewModel.userFetchedResultsController.userIDs = userIDs - } catch { await enter(state: Fail.self) } - } // end Task - } // end func didEnter + } + } } - + class NoMore: FollowerListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { @@ -176,6 +201,8 @@ extension FollowerListViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) + + viewModel?.tableView?.refreshControl?.endRefreshing() } } } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift index ab1144e54..b0d31417a 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift @@ -19,12 +19,16 @@ final class FollowerListViewModel { // 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 = { @@ -48,13 +52,9 @@ final class FollowerListViewModel { ) { self.context = context self.authContext = authContext - self.userFetchedResultsController = UserFetchedResultsController( - managedObjectContext: context.managedObjectContext, - domain: domain, - additionalPredicate: nil - ) self.domain = domain self.userID = userID - // end init + self.accounts = [] + self.relationships = [] } } diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index ecd26fb34..28fdac266 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -173,7 +173,6 @@ extension FollowingListViewController: UIScrollViewDelegate { if visibleBottomY > fetchThrottleOffsetY { viewModel.shouldFetch.send() } - } }