From 8c3040c0f9ef495ea3fc8eb9d4fdb34eecc1858b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Tue, 30 Mar 2021 14:16:08 +0800 Subject: [PATCH 01/19] feat: add hashtag timeline API --- .../API/Mastodon+API+Timeline.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 03a718b5b..6ab897123 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -16,6 +16,10 @@ extension Mastodon.API.Timeline { static func homeTimelineEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home") } + static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("tag/\(hashtag)") + } /// View public timeline statuses /// @@ -81,6 +85,38 @@ extension Mastodon.API.Timeline { .eraseToAnyPublisher() } + /// View public statuses containing the given hashtag. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/29 + /// # Reference + /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `HashtagTimelineQuery` with query parameters + /// - hashtag: Content of a #hashtag, not including # symbol. + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func hashtag( + session: URLSession, + domain: String, + query: HashtagTimelineQuery, + hashtag: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } public protocol TimelineQueryType { @@ -167,4 +203,41 @@ extension Mastodon.API.Timeline { } } + public struct HashtagTimelineQuery: Codable, TimelineQuery, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let local: Bool? + public let onlyMedia: Bool? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + local: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.local = local + self.onlyMedia = onlyMedia + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } + } From d548840bd913606e612a0eb885396614209a48a8 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Thu, 1 Apr 2021 10:12:57 +0800 Subject: [PATCH 02/19] feat: implement hashtag timeline --- Mastodon.xcodeproj/project.pbxproj | 44 +++ Mastodon/Coordinator/SceneCoordinator.swift | 7 + .../Extension/Array+removeDuplicates.swift | 23 ++ ...Provider+StatusTableViewCellDelegate.swift | 18 ++ Mastodon/Scene/Compose/ComposeViewModel.swift | 22 +- ...imelineViewController+StatusProvider.swift | 88 ++++++ .../HashtagTimelineViewController.swift | 279 ++++++++++++++++++ .../HashtagTimelineViewModel+Diffable.swift | 128 ++++++++ ...tagTimelineViewModel+LoadLatestState.swift | 104 +++++++ ...tagTimelineViewModel+LoadMiddleState.swift | 131 ++++++++ ...tagTimelineViewModel+LoadOldestState.swift | 121 ++++++++ .../HashtagTimelineViewModel.swift | 112 +++++++ .../Scene/Share/View/Content/StatusView.swift | 9 + .../TableviewCell/StatusTableViewCell.swift | 7 + .../APIService+HashtagTimeline.swift | 70 +++++ .../API/Mastodon+API+Timeline.swift | 8 +- 16 files changed, 1165 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Extension/Array+removeDuplicates.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+HashtagTimeline.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5179c3870..b56a389d9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -316,6 +325,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -634,6 +652,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { + isa = PBXGroup; + children = ( + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, + ); + path = HashtagTimeline; + sourceTree = ""; + }; 0FAA0FDD25E0B5700017CCDE /* Welcome */ = { isa = PBXGroup; children = ( @@ -1119,6 +1151,7 @@ DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, ); path = APIService; sourceTree = ""; @@ -1328,6 +1361,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 0F2021F5261325ED000C64BF /* HashtagTimeline */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, @@ -1369,6 +1403,7 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, ); path = Extension; sourceTree = ""; @@ -1892,6 +1927,7 @@ 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, @@ -1911,6 +1947,7 @@ 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, @@ -1941,6 +1978,7 @@ DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, @@ -1978,6 +2016,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, @@ -2005,6 +2044,7 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2012,7 +2052,9 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, @@ -2021,10 +2063,12 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..fb0603251 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -50,6 +50,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // Hashtag Timeline + case hashtagTimeline(viewModel: HashtagTimelineViewModel) + // misc case alertController(alertController: UIAlertController) @@ -206,6 +209,10 @@ private extension SceneCoordinator { ) } viewController = alertController + case .hashtagTimeline(let viewModel): + let _viewController = HashtagTimelineViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift new file mode 100644 index 000000000..c3a4b0384 --- /dev/null +++ b/Mastodon/Extension/Array+removeDuplicates.swift @@ -0,0 +1,23 @@ +// +// Array+removeDuplicates.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f3d31ff33..1ba6e60cf 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -192,3 +192,21 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +// MARK: - ActiveLabel didSelect ActiveEntity +extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let hashtag, let userInfo): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag) + coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show) + break + case .email(let content, let userInfo): + break + case .mention(let mention, let userInfo): + break + case .url(let content, let trimmed, let url, let userInfo): + break + } + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52ca4cc88..52a7bf2f4 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,6 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) + var injectedContent: String? = nil + // custom emojis var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) @@ -71,10 +73,12 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + injectedContent: String? = nil ) { self.context = context self.composeKind = composeKind + self.injectedContent = injectedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -195,9 +199,16 @@ final class ComposeViewModel { // bind modal dismiss state composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) - .map { content in + .map { [weak self] content in let content = content ?? "" - return content.isEmpty + if content.isEmpty { + return true + } + // if injectedContent plus a space is equal to the content, simply dismiss the modal + if let injectedContent = self?.injectedContent { + return content == (injectedContent + " ") + } + return false } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) @@ -304,6 +315,11 @@ final class ComposeViewModel { self.isPollToolbarButtonEnabled.value = !shouldPollDisable }) .store(in: &disposeBag) + + if let injectedContent = injectedContent { + // add a space after the injected text + composeStatusAttribute.composeContent.send(injectedContent + " ") + } } deinit { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..e4092ce0f --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -0,0 +1,88 @@ +// +// HashtagTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension HashtagTimelineViewController: StatusProvider { + + func toot() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .homeTimelineIndex(let objectID, _): + let managedObjectContext = self.viewModel.context.managedObjectContext + managedObjectContext.perform { + let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex + promise(.success(timelineIndex?.toot)) + } + default: + promise(.success(nil)) + } + } + } + + func toot(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.context.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift new file mode 100644 index 000000000..ba6d30e32 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -0,0 +1,279 @@ +// +// HashtagTimelineViewController.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit +import CoreData + +class HashtagTimelineViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: HashtagTimelineViewModel! + + let composeBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.Label.highlight.color + barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension HashtagTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "#\(viewModel.hashTag)" + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + navigationItem.rightBarButtonItem = composeBarButtonItem + + composeBarButtonItem.target = self + composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:)) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self + ) + + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } + tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + refreshControl.beginRefreshing() + refreshControl.sendActions(for: .valueChanged) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { _ in + // do nothing + } completion: { _ in + // fix AutoLayout cell height not update after rotate issue + self.viewModel.cellFrameCache.removeAllObjects() + self.tableView.reloadData() + } + } +} + +extension HashtagTimelineViewController { + + @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } +} + +// MARK: - UIScrollViewDelegate +extension HashtagTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) +// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + } +} + +extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +} + +// MARK: - UITableViewDelegate +extension HashtagTimelineViewController: UITableViewDelegate { + + // TODO: + // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + // + // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + // return 200 + // } + // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + // + // return ceil(frame.height) + // } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + + +// MARK: - UITableViewDataSourcePrefetching +extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineIndexObjectID = timelineIndexobjectID else { + return + } + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let _ = self else { return } + if let stateMachine = ids[upperTimelineIndexObjectID] { + guard let state = stateMachine.currentState else { + assertionFailure() + return + } + + // make success state same as loading due to snapshot updating delay + let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success + if isLoading { + cell.startAnimating() + } else { + cell.stopAnimating() + } + } else { + cell.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineIndexObjectID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + ]) + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self) + dict[upperTimelineIndexObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict + } + } + + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .homeMiddleLoader(let upper): + guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { + assertionFailure() + return + } + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self) + default: + assertionFailure() + } + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - StatusTableViewCellDelegate +extension HashtagTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..a41568787 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -0,0 +1,128 @@ +// +// HashtagTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + ) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + guard let tableView = self.tableView else { return } + guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + + let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + let oldSnapshot = diffableDataSource.snapshot() + let snapshot = snapshot as NSDiffableDataSourceSnapshot + + let statusItemList: [Item] = snapshot.itemIdentifiers.map { + let status = managedObjectContext.object(with: $0) as! Toot + + let isStatusTextSensitive: Bool = { + guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + } + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + + // Check if there is a `needLoadMiddleIndex` + if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { + // If yes, insert a `middleLoader` at the index + var newItems = statusItemList + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newSnapshot.appendItems(newItems, toSection: .main) + } else { + newSnapshot.appendItems(statusItemList, toSection: .main) + } + + if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + self.isFetchingLatestTimeline.value = false + return + } + + DispatchQueue.main.async { + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestTimeline.value = false + } + } + } + + private struct Difference { + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + + let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! + + guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil } + + if oldItemBeginIndexInNewSnapshot > 0 { + let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0) + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar) + return Difference( + targetIndexPath: targetIndexPath, + offset: offset + ) + } + return nil + } + +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..b3cb2cc3b --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,104 @@ +// +// HashtagTimelineViewModel+LoadLatestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +extension HashtagTimelineViewModel { + class LoadLatestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadLatestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadLatestState { + class Initial: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + // sign out when loading will enter here + stateMachine.enter(Fail.self) + return + } + // TODO: only set large count when using Wi-Fi + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + viewModel.isFetchingLatestTimeline.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + + } receiveValue: { response in + let newStatusIDList = response.value.map { $0.id } + + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) + } else { + viewModel.needLoadMiddleIndex = nil + } + + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) + viewModel.hashtagStatusIDList.removeDuplicates() + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..3c3b01d87 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,131 @@ +// +// HashtagTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: HashtagTimelineViewModel? + let upperStatusObjectID: NSManagedObjectID + + init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) { + self.viewModel = viewModel + self.upperStatusObjectID = upperStatusObjectID + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + var dict = viewModel.loadMiddleSateMachineList.value + dict[upperStatusObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict // trigger value change + } + } +} + +extension HashtagTimelineViewModel.LoadMiddleState { + + class Initial: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Success.self || stateClass == Fail.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + stateMachine.enter(Fail.self) + return + } + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + status.id + } + + // TODO: only set large count when using Wi-Fi + let maxID = upperStatusObject.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + // TODO: handle error + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(Success.self) + + let newStatusIDList = response.value.map { $0.id } + + if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count + } else { + viewModel.needLoadMiddleIndex = nil + } + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + viewModel.hashtagStatusIDList.removeDuplicates() + } else { + // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index + // Then there is no need to set a `loadMiddleState` cell + viewModel.needLoadMiddleIndex = nil + } + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..f503420a7 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,121 @@ +// +// HashtagTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadOldestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadOldestState { + class Initial: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + stateMachine.enter(Idle.self) + return + } + + // TODO: only set large count when using Wi-Fi + let maxID = last.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { response in + let toots = response.value + // enter no more state when no new toots + if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + let newStatusIDList = toots.map { $0.id } + viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift new file mode 100644 index 000000000..19144d199 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -0,0 +1,112 @@ +// +// HashtagTimelineViewModel.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class HashtagTimelineViewModel: NSObject { + + let hashTag: String + + var disposeBag = Set() + + var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() + var needLoadMiddleIndex: Int? = nil + + // input + let context: AppContext + let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) + let timelinePredicate = CurrentValueSubject(nil) + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + // top loader + private(set) lazy var loadLatestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadLatestState.Initial(viewModel: self), + LoadLatestState.Loading(viewModel: self), + LoadLatestState.Fail(viewModel: self), + LoadLatestState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadLatestState.Initial.self) + return stateMachine + }() + lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + // middle loader + let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine + var diffableDataSource: UITableViewDiffableDataSource? + var cellFrameCache = NSCache() + + + init(context: AppContext, hashTag: String) { + self.context = context + self.hashTag = hashTag + self.fetchedResultsController = { + let fetchRequest = Toot.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + timelinePredicate + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + self.diffableDataSource?.defaultRowAnimation = .fade + try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 6d7800b04..7db897f62 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -15,6 +15,7 @@ protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) } final class StatusView: UIView { @@ -400,6 +401,7 @@ extension StatusView { statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false playerContainerView.delegate = self + activeTextLabel.delegate = self contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) @@ -467,6 +469,13 @@ extension StatusView: AvatarConfigurableView { var configurableVerifiedBadgeImageView: UIImageView? { nil } } +// MARK: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity) + } +} + #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5f9bdf654..cb74bbf13 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -11,6 +11,7 @@ import AVKit import Combine import CoreData import CoreDataStack +import ActiveLabel protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } @@ -29,6 +30,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) } extension StatusTableViewCellDelegate { @@ -206,6 +209,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift new file mode 100644 index 000000000..d3e9d6208 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+HashtagTimeline.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func hashtagTimeline( + domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestTootMaxCount, + local: Bool? = nil, + hashtag: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Timeline.HashtagTimelineQuery( + maxID: maxID, + sinceID: sinceID, + minID: nil, // prefer sinceID + limit: limit, + local: local, + onlyMedia: false + ) + + return Mastodon.API.Timeline.hashtag( + session: session, + domain: domain, + query: query, + hashtag: hashtag, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, + response: response, + persistType: .lookUp, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 6ab897123..a9b5c4f12 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -18,7 +18,7 @@ extension Mastodon.API.Timeline { } static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { return Mastodon.API.endpointURL(domain: domain) - .appendingPathComponent("tag/\(hashtag)") + .appendingPathComponent("timelines/tag/\(hashtag)") } /// View public timeline statuses @@ -98,17 +98,19 @@ extension Mastodon.API.Timeline { /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `HashtagTimelineQuery` with query parameters /// - hashtag: Content of a #hashtag, not including # symbol. + /// - authorization: User token, auth is required if public preview is disabled /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func hashtag( session: URLSession, domain: String, query: HashtagTimelineQuery, - hashtag: String + hashtag: String, + authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), query: query, - authorization: nil + authorization: authorization ) return session.dataTaskPublisher(for: request) .tryMap { data, response in From b63a5ebe5faba3520375873469adf510333946ae Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 10:21:51 +0800 Subject: [PATCH 03/19] feat: use search api to fetch tag info --- Localization/app.json | 3 + Mastodon.xcodeproj/project.pbxproj | 12 ++ Mastodon/Generated/Strings.swift | 6 + .../Resources/en.lproj/Localizable.strings | 1 + .../HashtagTimelineViewController.swift | 28 +++- .../HashtagTimelineViewModel.swift | 19 +++ .../View/HashtagTimelineTitleView.swift | 59 +++++++++ .../API/Mastodon+API+Favorites.swift | 2 +- .../API/Mastodon+API+Notifications.swift | 125 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API+Search.swift | 19 ++- .../API/Mastodon+API+Timeline.swift | 4 +- .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+SearchResult.swift | 6 +- 13 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift diff --git a/Localization/app.json b/Localization/app.json index e2d64db0c..289f91277 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -240,6 +240,9 @@ "placeholder": "Search hashtags and users", "cancel": "Cancel" } + }, + "hashtag": { + "prompt": "%s people talking" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b56a389d9..9dc5cfb1d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -325,6 +326,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -652,9 +654,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F1E2D102615C39800C38565 /* View */ = { + isa = PBXGroup; + children = ( + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { isa = PBXGroup; children = ( + 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, @@ -1907,6 +1918,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1d2bd87a7..1afa816ff 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -249,6 +249,12 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") } } + internal enum Hashtag { + /// %@ people talking + internal static func prompt(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1)) + } + } internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index aecd96757..2dfa0ebc7 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -80,6 +80,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ba6d30e32..fd33cb883 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -95,13 +95,21 @@ extension HashtagTimelineViewController { } } .store(in: &disposeBag) + + viewModel.hashtagEntity + .receive(on: DispatchQueue.main) + .sink { [weak self] tag in + self?.updatePromptTitle() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + viewModel.fetchTag() guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } - tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + refreshControl.beginRefreshing() refreshControl.sendActions(for: .valueChanged) } @@ -123,6 +131,24 @@ extension HashtagTimelineViewController { self.tableView.reloadData() } } + + private func updatePromptTitle() { + guard let histories = viewModel.hashtagEntity.value?.history else { + navigationItem.prompt = nil + return + } + if histories.isEmpty { + // No tag history, remove the prompt title + navigationItem.prompt = nil + } else { + let sortedHistory = histories.sorted { (h1, h2) -> Bool in + return h1.day > h2.day + } + if let accountsNumber = sortedHistory.first?.accounts { + navigationItem.prompt = L10n.Scene.Hashtag.prompt(accountsNumber) + } + } + } } extension HashtagTimelineViewController { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 19144d199..8f2e07874 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -27,6 +27,7 @@ final class HashtagTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) + let hashtagEntity = CurrentValueSubject(nil) weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? @@ -105,6 +106,24 @@ final class HashtagTimelineViewModel: NSObject { .store(in: &disposeBag) } + func fetchTag() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags) + context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { _ in + + } receiveValue: { [weak self] response in + let matchedTag = response.value.hashtags.first { tag -> Bool in + return tag.name == self?.hashTag + } + self?.hashtagEntity.send(matchedTag) + } + .store(in: &disposeBag) + + } + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift new file mode 100644 index 000000000..04782bf63 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -0,0 +1,59 @@ +// +// HashtagTimelineTitleView.swift +// Mastodon +// +// Created by BradGao on 2021/4/1. +// + +import UIKit + +final class HashtagTimelineTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 3b01c2c13..cb01e83eb 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -121,7 +121,7 @@ extension Mastodon.API.Favorites { case destroy } - public struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,PagedQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift new file mode 100644 index 000000000..cdee82926 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -0,0 +1,125 @@ +// +// File.swift +// +// +// Created by BradGao on 2021/4/1. +// + +import Foundation +import Combine + +extension Mastodon.API.Notifications { + static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") + } + static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) + } + + /// Get all notifications + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `GetAllNotificationsQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getAll( + session: URLSession, + domain: String, + query: GetAllNotificationsQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: notificationsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Notification].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Get a single notification + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - notificationID: ID of the notification. + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func get( + session: URLSession, + domain: String, + notificationID: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Notification.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let excludeTypes: [String]? + public let accountID: String? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + excludeTypes: [String]? = nil, + accountID: String? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.excludeTypes = excludeTypes + self.accountID = accountID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let excludeTypes = excludeTypes { + excludeTypes.forEach { + items.append(URLQueryItem(name: "exclude_types[]", value: $0)) + } + } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 8f266437f..42dfc1e25 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -50,8 +50,21 @@ extension Mastodon.API.Search { } extension Mastodon.API.Search { + public enum SearchType: String, Codable { + case ccounts, hashtags, statuses + } + public struct Query: Codable, GetQuery { - public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { + public init(q: String, + type: SearchType? = nil, + accountID: Mastodon.Entity.Account.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + excludeUnreviewed: Bool? = nil, + resolve: Bool? = nil, + limit: Int? = nil, + offset: Int? = nil, + following: Bool? = nil) { self.accountID = accountID self.maxID = maxID self.minID = minID @@ -67,7 +80,7 @@ extension Mastodon.API.Search { public let accountID: Mastodon.Entity.Account.ID? public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? - public let type: String? + public let type: SearchType? public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. public let q: String public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false. @@ -80,7 +93,7 @@ extension Mastodon.API.Search { accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } - type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) } + type.flatMap { items.append(URLQueryItem(name: "type", value: $0.rawValue)) } excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } items.append(URLQueryItem(name: "q", value: q)) resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index a9b5c4f12..c1857ae82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -121,14 +121,14 @@ extension Mastodon.API.Timeline { } } -public protocol TimelineQueryType { +public protocol PagedQueryType { var maxID: Mastodon.Entity.Status.ID? { get } var sinceID: Mastodon.Entity.Status.ID? { get } } extension Mastodon.API.Timeline { - public typealias TimelineQuery = TimelineQueryType + public typealias TimelineQuery = PagedQueryType public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index ac960e710..cdb6c2f14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -107,6 +107,7 @@ extension Mastodon.API { public enum Search { } public enum Trends { } public enum Suggestions { } + public enum Notifications { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f06f1a54e..f10339664 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,9 +8,9 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { - let accounts: [Mastodon.Entity.Account] - let statuses: [Mastodon.Entity.Status] - let hashtags: [Mastodon.Entity.Tag] + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] } } From 4f77688d0393901269a934bb4600a429f209ac3f Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 16:38:33 +0800 Subject: [PATCH 04/19] feat: add nativation title view --- .../HashtagTimelineViewController.swift | 16 ++++-- .../View/HashtagTimelineTitleView.swift | 54 +++++++++++-------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index fd33cb883..58cf14d6d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -41,6 +41,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { let refreshControl = UIRefreshControl() + let titleView = HashtagTimelineNavigationBarTitleView() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } @@ -52,6 +54,9 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashTag)" + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil) + navigationItem.titleView = titleView + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color navigationItem.rightBarButtonItem = composeBarButtonItem @@ -133,20 +138,21 @@ extension HashtagTimelineViewController { } private func updatePromptTitle() { + var subtitle: String? + defer { + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle) + } guard let histories = viewModel.hashtagEntity.value?.history else { - navigationItem.prompt = nil return } if histories.isEmpty { // No tag history, remove the prompt title - navigationItem.prompt = nil + return } else { let sortedHistory = histories.sorted { (h1, h2) -> Bool in return h1.day > h2.day } - if let accountsNumber = sortedHistory.first?.accounts { - navigationItem.prompt = L10n.Scene.Hashtag.prompt(accountsNumber) - } + subtitle = sortedHistory.first?.accounts } } } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift index 04782bf63..78d5a971c 100644 --- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -7,20 +7,26 @@ import UIKit -final class HashtagTimelineTitleView: UIView { +final class HashtagTimelineNavigationBarTitleView: UIView { let containerView = UIStackView() - let imageView = UIImageView() - let button = RoundedEdgesButton() - let label = UILabel() + let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() - // input - private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? - weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? - - // output - private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.isHidden = true + return label + }() override init(frame: CGRect) { super.init(frame: frame) @@ -34,8 +40,11 @@ final class HashtagTimelineTitleView: UIView { } -extension HomeTimelineNavigationBarTitleView { +extension HashtagTimelineNavigationBarTitleView { private func _init() { + containerView.axis = .vertical + containerView.alignment = .center + containerView.distribution = .fill containerView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerView) NSLayoutConstraint.activate([ @@ -45,15 +54,18 @@ extension HomeTimelineNavigationBarTitleView { containerView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - containerView.addArrangedSubview(imageView) - button.translatesAutoresizingMaskIntoConstraints = false - containerView.addArrangedSubview(button) - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) - ]) - containerView.addArrangedSubview(label) - - configure(state: .logoImage) - button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + containerView.addArrangedSubview(titleLabel) + containerView.addArrangedSubview(subtitleLabel) + } + + func updateTitle(hashtag: String, peopleNumber: String?) { + titleLabel.text = "#\(hashtag)" + if let peopleNumebr = peopleNumber { + subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) + subtitleLabel.isHidden = false + } else { + subtitleLabel.text = nil + subtitleLabel.isHidden = true + } } } From a9d35109fd73d5df2d84b0831ec5ad058ea7d39b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 17:05:06 +0800 Subject: [PATCH 05/19] feat: update mechanism of calculating number of people taking tags --- .../HashtagTimeline/HashtagTimelineViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 58cf14d6d..e82bb31ae 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -152,7 +152,11 @@ extension HashtagTimelineViewController { let sortedHistory = histories.sorted { (h1, h2) -> Bool in return h1.day > h2.day } - subtitle = sortedHistory.first?.accounts + let peopleTalkingNumber = sortedHistory + .prefix(2) + .compactMap({ Int($0.accounts) }) + .reduce(0, +) + subtitle = "\(peopleTalkingNumber)" } } } From 5d3b6d1943bf4f7bc290e23ce051f16055323210 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 18:13:45 +0800 Subject: [PATCH 06/19] feat: handle profile follow, block, and mute actions --- .../CoreData.xcdatamodel/contents | 10 +- CoreDataStack/Entity/MastodonUser.swift | 23 + Localization/app.json | 19 +- Mastodon.xcodeproj/project.pbxproj | 46 +- Mastodon/Coordinator/SceneCoordinator.swift | 18 + Mastodon/Diffiable/Section/PollSection.swift | 12 + .../CoreDataStack/MastodonUser.swift | 9 +- Mastodon/Generated/Assets.swift | 5 + Mastodon/Generated/Strings.swift | 40 ++ Mastodon/Helper/MastodonMetricFormatter.swift | 41 ++ .../Protocol/AvatarConfigurableView.swift | 28 +- .../Protocol/UserProvider/UserProvider.swift | 16 + .../UserProvider/UserProviderFacade.swift | 204 +++++++++ .../Profile/Banner/Contents.json | 9 + .../username.gray.colorset/Contents.json | 20 + .../Assets.xcassets/Profile/Contents.json | 9 + .../Resources/en.lproj/Localizable.strings | 11 + .../Header/ProfileHeaderViewController.swift | 34 +- .../View/ProfileFriendshipActionButton.swift | 71 --- .../Header/View/ProfileHeaderView.swift | 53 ++- .../ProfileRelationshipActionButton.swift | 40 ++ .../ProfileViewController+UserProvider.swift | 20 + .../Scene/Profile/ProfileViewController.swift | 420 +++++++++--------- Mastodon/Scene/Profile/ProfileViewModel.swift | 251 +++++++++-- .../Service/APIService/APIService+Block.swift | 167 +++++++ .../APIService/APIService+Follow.swift | 187 ++++++++ .../Service/APIService/APIService+Mute.swift | 167 +++++++ .../APIService/APIService+Relationship.swift | 84 ++-- .../APIService+CoreData+MastodonUser.swift | 1 + .../API/Mastodon+API+Account+Friendship.swift | 347 +++++++++++++++ 30 files changed, 1960 insertions(+), 402 deletions(-) create mode 100644 Mastodon/Helper/MastodonMetricFormatter.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProvider.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProviderFacade.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Contents.json delete mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift create mode 100644 Mastodon/Service/APIService/APIService+Block.swift create mode 100644 Mastodon/Service/APIService/APIService+Follow.swift create mode 100644 Mastodon/Service/APIService/APIService+Mute.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index d655753d3..e2f059d50 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -69,6 +69,7 @@ + @@ -78,6 +79,7 @@ + @@ -197,12 +199,12 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 2228787b5..878eb9ad4 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -29,6 +29,9 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var followingCount: NSNumber @NSManaged public private(set) var followersCount: NSNumber + @NSManaged public private(set) var locked: Bool + @NSManaged public private(set) var bot: Bool + @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -88,6 +91,9 @@ extension MastodonUser { user.followingCount = NSNumber(value: property.followingCount) user.followersCount = NSNumber(value: property.followersCount) + user.locked = property.locked + user.bot = property.bot ?? false + // Mastodon do not provide relationship on the `Account` // Update relationship via attribute updating interface @@ -158,6 +164,17 @@ extension MastodonUser { self.followersCount = NSNumber(value: followersCount) } } + public func update(locked: Bool) { + if self.locked != locked { + self.locked = locked + } + } + public func update(bot: Bool) { + if self.bot != bot { + self.bot = bot + } + } + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { if isFollowing { if !(self.followingBy ?? Set()).contains(mastodonUser) { @@ -249,6 +266,8 @@ extension MastodonUser { public let statusesCount: Int public let followingCount: Int public let followersCount: Int + public let locked: Bool + public let bot: Bool? public let createdAt: Date public let networkDate: Date @@ -268,6 +287,8 @@ extension MastodonUser { statusesCount: Int, followingCount: Int, followersCount: Int, + locked: Bool, + bot: Bool?, createdAt: Date, networkDate: Date ) { @@ -286,6 +307,8 @@ extension MastodonUser { self.statusesCount = statusesCount self.followingCount = followingCount self.followersCount = followersCount + self.locked = locked + self.bot = bot self.createdAt = createdAt self.networkDate = networkDate } diff --git a/Localization/app.json b/Localization/app.json index 812a801ac..9eaba58f4 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,9 +69,16 @@ "firendship": { "follow": "Follow", "following": "Following", + "pending": "Pending", "block": "Block", + "block_user": "Block %s", + "unblock": "Unblock", + "unblock_user": "Unblock %s", "blocked": "Blocked", "mute": "Mute", + "mute_user": "Mute %s", + "unmute": "Unmute", + "unmute_user": "Unmute %s", "muted": "Muted", "edit_info": "Edit info" }, @@ -257,6 +264,16 @@ "posts": "Posts", "replies": "Replies", "media": "Media" + }, + "relationship_action_alert": { + "confirm_unmute_user": { + "title": "Unmute Account", + "message": "Confirm unmute %s" + }, + "confirm_unblock_usre": { + "title": "Unblock Account", + "message": "Confirm unblock %s" + } } }, "search": { @@ -266,4 +283,4 @@ } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e86ad664..2d5d0486b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -134,7 +134,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; }; + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; @@ -259,6 +259,13 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */; }; + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -473,7 +480,7 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; @@ -604,6 +611,13 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = ""; }; + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = ""; }; + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -885,6 +899,7 @@ isa = PBXGroup; children = ( 2D38F1FC25CD47D900561493 /* StatusProvider */, + DBAE3F742615DD63004B8251 /* UserProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, @@ -1190,6 +1205,9 @@ 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, ); path = APIService; sourceTree = ""; @@ -1400,8 +1418,8 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, + 5D03938E2612D200007FE196 /* Webview */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, @@ -1503,6 +1521,7 @@ DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, @@ -1537,6 +1556,7 @@ children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, ); path = Helper; sourceTree = ""; @@ -1558,6 +1578,15 @@ path = View; sourceTree = ""; }; + DBAE3F742615DD63004B8251 /* UserProvider */ = { + isa = PBXGroup; + children = ( + DBAE3F672615DD60004B8251 /* UserProvider.swift */, + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, + ); + path = UserProvider; + sourceTree = ""; + }; DBB525132611EBB1002F1F29 /* Segmented */ = { isa = PBXGroup; children = ( @@ -1603,7 +1632,7 @@ DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, ); path = View; @@ -1990,6 +2019,7 @@ DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, @@ -2028,6 +2058,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -2096,6 +2127,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2106,7 +2138,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, @@ -2128,6 +2160,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, @@ -2152,12 +2185,14 @@ DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, @@ -2216,6 +2251,7 @@ DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index d578ee528..4b6eed7ba 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -56,6 +56,7 @@ extension SceneCoordinator { // misc case alertController(alertController: UIAlertController) + case safari(url: URL) #if DEBUG case publicTimeline @@ -111,6 +112,17 @@ extension SceneCoordinator { guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else { return nil } + // adapt for child controller + if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { + switch viewController { + case is ProfileViewController: + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem + default: + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil + } + } if let mainTabBarController = presentingViewController as? MainTabBarController, let navigationController = mainTabBarController.selectedViewController as? UINavigationController, @@ -222,6 +234,12 @@ private extension SceneCoordinator { ) } viewController = alertController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 2f9404410..044f4fb9d 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -9,6 +9,18 @@ import UIKit import CoreData import CoreDataStack +import MastodonSDK + +extension Mastodon.Entity.Attachment: Hashable { + public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + enum PollSection: Equatable, Hashable { case main } diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 471adb815..e140ab95a 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -26,6 +26,8 @@ extension MastodonUser.Property { statusesCount: entity.statusesCount, followingCount: entity.followingCount, followersCount: entity.followersCount, + locked: entity.locked, + bot: entity.bot, createdAt: entity.createdAt, networkDate: networkDate ) @@ -39,7 +41,12 @@ extension MastodonUser { } var acctWithDomain: String { - return username + "@" + domain + if !acct.contains("@") { + // Safe concat due to username cannot contains "@" + return username + "@" + domain + } else { + return acct + } } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8276cfb20..0abcc2341 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -93,6 +93,11 @@ internal enum Asset { internal enum Connectivity { internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") } + internal enum Profile { + internal enum Banner { + internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") + } + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a308033fc..53ef603e2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -92,6 +92,10 @@ internal enum L10n { internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Block %@ + internal static func blockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1)) + } /// Edit info internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") /// Follow @@ -102,6 +106,24 @@ internal enum L10n { internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") /// Muted internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + /// Mute %@ + internal static func muteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.MuteUser", String(describing: p1)) + } + /// Pending + internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Unblock + internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") + /// Unblock %@ + internal static func unblockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnblockUser", String(describing: p1)) + } + /// Unmute + internal static let unmute = L10n.tr("Localizable", "Common.Controls.Firendship.Unmute") + /// Unmute %@ + internal static func unmuteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnmuteUser", String(describing: p1)) + } } internal enum Status { /// Tap to reveal that may be sensitive @@ -290,6 +312,24 @@ internal enum L10n { /// posts internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") } + internal enum RelationshipActionAlert { + internal enum ConfirmUnblockUsre { + /// Confirm unblock %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1)) + } + /// Unblock Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title") + } + internal enum ConfirmUnmuteUser { + /// Confirm unmute %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1)) + } + /// Unmute Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title") + } + } internal enum SegmentedControl { /// Media internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") diff --git a/Mastodon/Helper/MastodonMetricFormatter.swift b/Mastodon/Helper/MastodonMetricFormatter.swift new file mode 100644 index 000000000..0711669fb --- /dev/null +++ b/Mastodon/Helper/MastodonMetricFormatter.swift @@ -0,0 +1,41 @@ +// +// MastodonMetricFormatter.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import Foundation + +final class MastodonMetricFormatter: Formatter { + + func string(from number: Int) -> String? { + let isPositive = number >= 0 + let symbol = isPositive ? "" : "-" + + let numberFormatter = NumberFormatter() + + let value = abs(number) + let metric: String + + switch value { + case 0..<1000: // 0 ~ 1K + metric = String(value) + case 1000..<10000: // 1K ~ 10K + numberFormatter.maximumFractionDigits = 1 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + case 10000..<1000000: // 10K ~ 1M + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + default: + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000) + metric = string + "M" + } + + return symbol + metric + } + +} diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6391066e1..b8c5285a2 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -84,6 +84,8 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarImageView, configuration: configuration) } if let avatarButton = configurableAvatarButton { @@ -110,9 +112,24 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarButton, configuration: configuration) } } + func configureLayerBorder(view: UIView, configuration: AvatarConfigurableViewConfiguration) { + guard let borderWidth = configuration.borderWidth, borderWidth > 0, + let borderColor = configuration.borderColor else { + return + } + + view.layer.masksToBounds = true + view.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = borderColor.cgColor + view.layer.borderWidth = borderWidth + } + func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { } } @@ -121,10 +138,19 @@ struct AvatarConfigurableViewConfiguration { let avatarImageURL: URL? let placeholderImage: UIImage? + let borderColor: UIColor? + let borderWidth: CGFloat? - init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) { + init( + avatarImageURL: URL?, + placeholderImage: UIImage? = nil, + borderColor: UIColor? = nil, + borderWidth: CGFloat? = nil + ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage + self.borderColor = borderColor + self.borderWidth = borderWidth } } diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift new file mode 100644 index 000000000..63a1f8e68 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -0,0 +1,16 @@ +// +// UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { + // async + func mastodonUser() -> Future +} diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift new file mode 100644 index 000000000..04297772b --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -0,0 +1,204 @@ +// +// UserProviderFacade.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +enum UserProviderFacade { } + +extension UserProviderFacade { + + static func toggleUserFollowRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserFollowRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserFollowRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserBlockRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserBlockRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleBlock( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserMuteRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserMuteRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleMute( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func createProfileActionMenu( + for mastodonUser: MastodonUser, + isMuting: Bool, + isBlocking: Bool, + provider: UserProvider + ) -> UIMenu { + var children: [UIMenuElement] = [] + let name = mastodonUser.displayNameWithFallback + + // mute + let muteAction = UIAction( + title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, + image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), + discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), + attributes: isMuting ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserMuteRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isMuting { + children.append(muteAction) + } else { + let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) + children.append(muteMenu) + } + + // block + let blockAction = UIAction( + title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, + image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), + discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), + attributes: isBlocking ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserBlockRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isBlocking { + children.append(blockAction) + } else { + let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) + children.append(blockMenu) + } + + return UIMenu(title: "", options: [], children: children) + } + +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json new file mode 100644 index 000000000..473d42adc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0.961", + "green" : "0.922", + "red" : "0.922" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 88ecd2508..4a9b7bd30 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -29,12 +29,19 @@ Please check your internet connection."; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; "Common.Controls.Firendship.Follow" = "Follow"; "Common.Controls.Firendship.Following" = "Following"; "Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -96,6 +103,10 @@ tap the link to confirm your account."; "Scene.Profile.Dashboard.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 58a7a6110..855581902 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -21,7 +21,7 @@ final class ProfileHeaderViewController: UIViewController { weak var delegate: ProfileHeaderViewControllerDelegate? - let profileBannerView = ProfileHeaderView() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) segmenetedControl.selectedSegmentIndex = 0 @@ -31,7 +31,7 @@ final class ProfileHeaderViewController: UIViewController { private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 - private var isAdjustBannerImageViewForSafeAreaInset = false + // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero deinit { @@ -47,19 +47,19 @@ extension ProfileHeaderViewController { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - profileBannerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(profileBannerView) + profileHeaderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ - profileBannerView.topAnchor.constraint(equalTo: view.topAnchor), - profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), + profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - profileBannerView.preservesSuperviewLayoutMargins = true + profileHeaderView.preservesSuperviewLayoutMargins = true pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false view.addSubview(pageSegmentedControl) NSLayoutConstraint.activate([ - pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), @@ -72,11 +72,13 @@ extension ProfileHeaderViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !isAdjustBannerImageViewForSafeAreaInset { - isAdjustBannerImageViewForSafeAreaInset = true - profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top - profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top - } + // Deprecated: + // not needs this tweak due to force layout update in the parent + // if !isAdjustBannerImageViewForSafeAreaInset { + // isAdjustBannerImageViewForSafeAreaInset = true + // profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + // profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top + // } } override func viewDidLayoutSubviews() { @@ -115,13 +117,13 @@ extension ProfileHeaderViewController { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) updateHeaderBottomShadow(progress: progress) - let bannerImageView = profileBannerView.bannerImageView + let bannerImageView = profileHeaderView.bannerImageView guard bannerImageView.bounds != .zero else { // wait layout finish return } - let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil) + let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift deleted file mode 100644 index 286145ffd..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ProfileFriendshipActionButton.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit - -final class ProfileFriendshipActionButton: RoundedEdgesButton { - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ProfileFriendshipActionButton { - private func _init() { - configure(state: .follow) - } -} - -extension ProfileFriendshipActionButton { - enum State { - case follow - case following - case blocked - case muted - case edit - case editing - - var title: String { - switch self { - case .follow: return L10n.Common.Controls.Firendship.follow - case .following: return L10n.Common.Controls.Firendship.following - case .blocked: return L10n.Common.Controls.Firendship.blocked - case .muted: return L10n.Common.Controls.Firendship.muted - case .edit: return L10n.Common.Controls.Firendship.editInfo - case .editing: return L10n.Common.Controls.Actions.done - } - } - - var backgroundColor: UIColor { - switch self { - case .follow: return Asset.Colors.Button.normal.color - case .following: return Asset.Colors.Button.normal.color - case .blocked: return Asset.Colors.Background.danger.color - case .muted: return Asset.Colors.Background.alertYellow.color - case .edit: return Asset.Colors.Button.normal.color - case .editing: return Asset.Colors.Button.normal.color - } - } - } - - private func configure(state: State) { - setTitle(state.title, for: .normal) - setTitleColor(.white, for: .normal) - setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - } -} - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 7fac52896..a6b1f275c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import ActiveLabel protocol ProfileHeaderViewDelegate: class { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) @@ -22,6 +23,7 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + static let bannerImageViewPlaceholderColor = UIColor.systemGray weak var delegate: ProfileHeaderViewDelegate? @@ -29,10 +31,18 @@ final class ProfileHeaderView: UIView { let bannerImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill - imageView.image = .placeholder(color: .systemGray) + imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) imageView.layer.masksToBounds = true + // #if DEBUG + // imageView.image = .placeholder(color: .red) + // #endif return imageView }() + let bannerImageViewOverlayView: UIView = { + let overlayView = UIView() + overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + return overlayView + }() let avatarImageView: UIImageView = { let imageView = UIImageView() @@ -59,14 +69,18 @@ final class ProfileHeaderView: UIView { label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 - label.textColor = .white + label.textColor = Asset.Profile.Banner.usernameGray.color label.text = "@alice" label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return label }() let statusDashboardView = ProfileStatusDashboardView() - let friendshipActionButton = ProfileFriendshipActionButton() + let relationshipActionButton: ProfileRelationshipActionButton = { + let button = ProfileRelationshipActionButton() + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + return button + }() let bioContainerView = UIView() let fieldContainerStackView = UIStackView() @@ -103,6 +117,15 @@ extension ProfileHeaderView { bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] bannerImageView.frame = bannerContainerView.bounds bannerContainerView.addSubview(bannerImageView) + + bannerImageViewOverlayView.translatesAutoresizingMaskIntoConstraints = false + bannerImageView.addSubview(bannerImageViewOverlayView) + NSLayoutConstraint.activate([ + bannerImageViewOverlayView.topAnchor.constraint(equalTo: bannerImageView.topAnchor), + bannerImageViewOverlayView.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor), + bannerImageViewOverlayView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), + bannerImageViewOverlayView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), + ]) // avatar avatarImageView.translatesAutoresizingMaskIntoConstraints = false @@ -156,14 +179,14 @@ extension ProfileHeaderView { statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), ]) - friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(friendshipActionButton) + relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(relationshipActionButton) NSLayoutConstraint.activate([ - friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), - friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), - friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), - friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), - friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), ]) bioContainerView.preservesSuperviewLayoutMargins = true @@ -184,10 +207,20 @@ extension ProfileHeaderView { bringSubviewToFront(nameContainerStackView) bioActiveLabel.delegate = self + + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) } } +extension ProfileHeaderView { + @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + assert(sender === relationshipActionButton) + delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) + } +} + // MARK: - ActiveLabelDelegate extension ProfileHeaderView: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift new file mode 100644 index 000000000..b098c1ec1 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -0,0 +1,40 @@ +// +// ProfileRelationshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileRelationshipActionButton: RoundedEdgesButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileRelationshipActionButton { + private func _init() { + // do nothing + } +} + +extension ProfileRelationshipActionButton { + func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) { + setTitle(actionOptionSet.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } +} + diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift new file mode 100644 index 000000000..3a26db1c1 --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -0,0 +1,20 @@ +// +// ProfileViewController+UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine +import CoreDataStack + +extension ProfileViewController: UserProvider { + + func mastodonUser() -> Future { + return Future { promise in + promise(.success(self.viewModel.mastodonUser.value)) + } + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 4b74ad632..57a398b4b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,11 +18,17 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! - private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + let moreMenuBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + return barButtonItem + }() let refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() @@ -78,7 +84,7 @@ extension ProfileViewController { height: bottomPageHeight + headerViewHeight ) self.overlayScrollView.contentSize = contentSize - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) } } @@ -86,7 +92,7 @@ extension ProfileViewController { extension ProfileViewController { override var preferredStatusBarStyle: UIStatusBarStyle { - return preferredStatusBarStyleForBanner + return .lightContent } override func viewSafeAreaInsetsDidChange() { @@ -95,25 +101,45 @@ extension ProfileViewController { profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) } + override var isViewLoaded: Bool { + return super.isViewLoaded + } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + let barAppearance = UINavigationBarAppearance() barAppearance.configureWithTransparentBackground() navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance + navigationItem.titleView = UIView() -// if navigationController?.viewControllers.first == self { -// navigationItem.leftBarButtonItem = avatarBarButtonItem -// } -// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) - -// unmuteMenuBarButtonItem.target = self -// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:)) + Publishers.CombineLatest( + viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), + viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + guard let self = self else { return } + var items: [UIBarButtonItem] = [] + if !isReplyBarButtonItemHidden { + items.append(self.replyBarButtonItem) + } + if !isMoreMenuBarButtonItemHidden { + items.append(self.moreMenuBarButtonItem) + } + guard !items.isEmpty else { + self.navigationItem.rightBarButtonItems = nil + return + } + self.navigationItem.rightBarButtonItems = items + } + .store(in: &disposeBag) + // Publishers.CombineLatest4( // viewModel.muted.eraseToAnyPublisher(), @@ -244,23 +270,15 @@ extension ProfileViewController { profileHeaderViewController.delegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self -// // add segmented bar to header -// profileSegmentedViewController.pagingViewController.addBar( -// bar, -// dataSource: profileSegmentedViewController.pagingViewController.viewModel, -// at: .custom(view: profileHeaderViewController.view, layout: { bar in -// bar.translatesAutoresizingMaskIntoConstraints = false -// self.profileHeaderViewController.view.addSubview(bar) -// NSLayoutConstraint.activate([ -// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), -// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), -// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), -// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh), -// ]) -// }) -// ) - // bind view model + viewModel.name + .receive(on: DispatchQueue.main) + .sink { [weak self] name in + guard let self = self else { return } + self.title = name + } + .store(in: &disposeBag) + Publishers.CombineLatest( viewModel.bannerImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -268,56 +286,29 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] bannerImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color) + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage( + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( withURL: bannerImageURL, placeholderImage: placeholder, imageTransition: .crossDissolve(0.3), runImageTransitionIfCached: false, completion: { [weak self] response in guard let self = self else { return } - switch response.result { - case .success(let image): - self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark - case .failure: - break + guard let image = response.value else { return } + guard image.size.width > 1 && image.size.height > 1 else { + // restore to placeholder when image invalid + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder + return } } ) } .store(in: &disposeBag) - viewModel.headerDomainLumaStyle - .receive(on: DispatchQueue.main) - .sink { [weak self] style in - guard let self = self else { return } - let textColor: UIColor - let shadowColor: UIColor - switch style { - case .light: - self.preferredStatusBarStyleForBanner = .darkContent - textColor = .black - shadowColor = .white - case .dark: - self.preferredStatusBarStyleForBanner = .lightContent - textColor = .white - shadowColor = .black - default: - self.preferredStatusBarStyleForBanner = .default - textColor = .white - shadowColor = .black - } - - self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - } - .store(in: &disposeBag) Publishers.CombineLatest( viewModel.avatarImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -325,147 +316,100 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] avatarImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.configure( - with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL) + self.profileHeaderViewController.profileHeaderView.configure( + with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2) ) } .store(in: &disposeBag) -// viewModel.protected -// .map { $0 != true } -// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView) -// .store(in: &disposeBag) viewModel.name .map { $0 ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) .store(in: &disposeBag) viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) .store(in: &disposeBag) -// viewModel.friendship -// .sink { [weak self] friendship in -// guard let self = self else { return } -// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton -// followingButton.isHidden = friendship == nil -// -// if let friendship = friendship { -// switch friendship { -// case .following: followingButton.style = .following -// case .pending: followingButton.style = .pending -// case .none: followingButton.style = .follow -// } -// } -// } -// .store(in: &disposeBag) -// viewModel.followedBy -// .sink { [weak self] followedBy in -// guard let self = self else { return } -// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel -// followStatusLabel.isHidden = followedBy != true -// } -// .store(in: &disposeBag) -// + viewModel.relationshipActionOptionSet + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionOptionSet in + guard let self = self else { return } + guard let mastodonUser = self.viewModel.mastodonUser.value else { + self.moreMenuBarButtonItem.menu = nil + return + } + let isMuting = relationshipActionOptionSet.contains(.muting) + let isBlocking = relationshipActionOptionSet.contains(.blocking) + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self) + } + .store(in: &disposeBag) + viewModel.isRelationshipActionButtonHidden + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + guard let self = self else { return } + self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden + } + .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), + viewModel.isEditing.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionSet, isEditing in + guard let self = self else { return } + let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton + if relationshipActionSet.contains(.edit) { + friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit) + } else { + friendshipButton.configure(actionOptionSet: relationshipActionSet) + } + } + .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] bio in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "") + self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "") }) .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.url.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] url, isSuspended in -// guard let self = self else { return } -// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal) -// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.location.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] location, isSuspended in -// guard let self = self else { return } -// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal) -// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) viewModel.statusesCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followingCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followersCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) -// viewModel.followersCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.listedCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.suspended -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isSuspended in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended -// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended -// if isSuspended { -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileTweetPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileMediaPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserMediaTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileLikesPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserLikeTimelineViewModel.State.Suspended.self) -// } -// } -// .store(in: &disposeBag) - -// - profileHeaderViewController.profileBannerView.delegate = self + + profileHeaderViewController.profileHeaderView.delegate = self } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // set back button tint color in SceneCoordinator.present(scene:from:transition:) + + // force layout to make banner image tweak take effect + view.layoutIfNeeded() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + viewModel.viewDidAppear.send() // set overlay scroll view initial content size @@ -483,6 +427,11 @@ extension ProfileViewController { extension ProfileViewController { + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + // TODO: + } + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -600,62 +549,97 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // setup observer and gesture fallback currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - - -// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController, -// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserMediaTimelineViewModel.State.NoMore, -// is UserMediaTimelineViewModel.State.NotAuthorized, -// is UserMediaTimelineViewModel.State.Blocked: -// break -// default: -// if userMediaTimelineViewController.viewModel.items.value.isEmpty { -// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self) -// } -// } -// } -// -// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController, -// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserLikeTimelineViewModel.State.NoMore, -// is UserLikeTimelineViewModel.State.NotAuthorized, -// is UserLikeTimelineViewModel.State.Blocked: -// break -// default: -// if userLikeTimelineViewController.viewModel.items.value.isEmpty { -// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self) -// } -// } -// } } } -// MARK: - ProfileBannerInfoActionViewDelegate -//extension ProfileViewController: ProfileBannerInfoActionViewDelegate { -// -// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) { -// UserProviderFacade -// .toggleUserFriendship(provider: self, sender: button) -// .sink { _ in -// // do nothing -// } receiveValue: { _ in -// // do nothing -// } -// .store(in: &disposeBag) -// } -// -//} - // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { + let relationshipActionSet = viewModel.relationshipActionOptionSet.value + if relationshipActionSet.contains(.edit) { + viewModel.isEditing.value.toggle() + } else { + guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } + switch relationshipAction { + case .none: + break + case .follow, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + + } receiveValue: { _ in + + } + .store(in: &disposeBag) + case .pending: + break + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), + preferredStyle: .alert + ) + let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserMuteRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unmuteAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocking: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), + preferredStyle: .alert + ) + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserBlockRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocked: + break + default: + assertionFailure() + } + + } } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + switch entity.type { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + // TODO: + break + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index f7248009d..5df8952b8 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -26,14 +26,12 @@ class ProfileViewModel: NSObject { let mastodonUser: CurrentValueSubject let currentMastodonUser = CurrentValueSubject(nil) let viewDidAppear = PassthroughSubject() - let headerDomainLumaStyle = CurrentValueSubject(.dark) // default dark for placeholder banner // output let domain: CurrentValueSubject let userID: CurrentValueSubject let bannerImageURL: CurrentValueSubject let avatarImageURL: CurrentValueSubject -// let protected: CurrentValueSubject let name: CurrentValueSubject let username: CurrentValueSubject let bioDescription: CurrentValueSubject @@ -42,13 +40,19 @@ class ProfileViewModel: NSObject { let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject -// let friendship: CurrentValueSubject -// let followedBy: CurrentValueSubject -// let muted: CurrentValueSubject -// let blocked: CurrentValueSubject -// -// let suspended = CurrentValueSubject(false) -// + let protected: CurrentValueSubject + // let suspended: CurrentValueSubject + + let relationshipActionOptionSet = CurrentValueSubject(.none) + let isEditing = CurrentValueSubject(false) + let isFollowedBy = CurrentValueSubject(false) + let isMuting = CurrentValueSubject(false) + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) + + let isRelationshipActionButtonHidden = CurrentValueSubject(true) + let isReplyBarButtonItemHidden = CurrentValueSubject(true) + let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context @@ -65,11 +69,14 @@ class ProfileViewModel: NSObject { self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) -// self.friendship = CurrentValueSubject(nil) -// self.followedBy = CurrentValueSubject(nil) -// self.muted = CurrentValueSubject(false) -// self.blocked = CurrentValueSubject(false) + self.protected = CurrentValueSubject(mastodonUser?.locked) super.init() + + relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: \.value, on: isRelationshipActionButtonHidden) + .store(in: &disposeBag) // bind active authentication context.authenticationService.activeMastodonAuthentication @@ -84,26 +91,54 @@ class ProfileViewModel: NSObject { self.currentMastodonUser.value = activeMastodonAuthentication.user } .store(in: &disposeBag) - - setup() - } - -} - -extension ProfileViewModel { - - enum Friendship: CustomDebugStringConvertible { - case following - case pending - case none - var debugDescription: String { - switch self { - case .following: return "following" - case .pending: return "pending" - case .none: return "none" + // query relationship + let mastodonUserID = self.mastodonUser.map { $0?.id } + let pendingRetryPublisher = CurrentValueSubject(1) + + Publishers.CombineLatest3( + mastodonUserID.removeDuplicates().eraseToAnyPublisher(), + context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(), + pendingRetryPublisher.eraseToAnyPublisher() + ) + .compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.MastodonAuthenticationBox)? in + guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil } + guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil } + return (mastodonUserID, activeMastodonAuthenticationBox) + } + .setFailureType(to: Error.self) // allow failure + .flatMap { mastodonUserID, activeMastodonAuthenticationBox -> AnyPublisher, Error> in + let domain = activeMastodonAuthenticationBox.domain + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch for user %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUserID) + + return self.context.apiService.relationship(domain: domain, accountIDs: [mastodonUserID], authorizationBox: activeMastodonAuthenticationBox) + //.retry(3) + .eraseToAnyPublisher() + } + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update success", ((#file as NSString).lastPathComponent), #line, #function) + + // there are seconds delay after request follow before requested -> following. Query again when needs + guard let relationship = response.value.first else { return } + if relationship.requested == true { + let delay = pendingRetryPublisher.value + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let _ = self else { return } + pendingRetryPublisher.value = min(2 * delay, 60) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) + } } } + .store(in: &disposeBag) + + setup() } } @@ -117,9 +152,11 @@ extension ProfileViewModel { .receive(on: DispatchQueue.main) .sink { [weak self] mastodonUser, currentMastodonUser in guard let self = self else { return } + // Update view model attribute self.update(mastodonUser: mastodonUser) self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + // Setup observer for user if let mastodonUser = mastodonUser { // setup observer self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) @@ -147,6 +184,7 @@ extension ProfileViewModel { self.mastodonUserObserver = nil } + // Setup observer for user if let currentMastodonUser = currentMastodonUser { // setup observer self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) @@ -179,7 +217,6 @@ extension ProfileViewModel { self.userID.value = mastodonUser?.id self.bannerImageURL.value = mastodonUser?.headerImageURL() self.avatarImageURL.value = mastodonUser?.avatarImageURL() -// self.protected.value = twitterUser?.protected self.name.value = mastodonUser?.displayNameWithFallback self.username.value = mastodonUser?.acctWithDomain self.bioDescription.value = mastodonUser?.note @@ -187,11 +224,159 @@ extension ProfileViewModel { self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + self.protected.value = mastodonUser?.locked } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { - // TODO: + guard let mastodonUser = mastodonUser, + let currentMastodonUser = currentMastodonUser else { + // set relationship + self.relationshipActionOptionSet.value = .none + self.isFollowedBy.value = false + self.isMuting.value = false + self.isBlocking.value = false + self.isBlockedBy.value = false + + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + return + } + + if mastodonUser == currentMastodonUser { + self.relationshipActionOptionSet.value = [.edit] + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + } else { + // set with follow action default + var relationshipActionSet = RelationshipActionOptionSet([.follow]) + + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isFollowing { + relationshipActionSet.insert(.following) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) + + let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isPending { + relationshipActionSet.insert(.pending) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) + + let isFollowedBy = currentMastodonUser.followingBy.flatMap { $0.contains(mastodonUser) } ?? false + self.isFollowedBy.value = isFollowedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) + + let isMuting = mastodonUser.mutingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isMuting { + relationshipActionSet.insert(.muting) + } + self.isMuting.value = isMuting + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) + + let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlocking { + relationshipActionSet.insert(.blocking) + } + self.isBlocking.value = isBlocking + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) + + let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + self.isBlockedBy.value = isBlockedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) + + self.relationshipActionOptionSet.value = relationshipActionSet + + // set bar button item state + self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy + self.isMoreMenuBarButtonItemHidden.value = false + } } - +} + +extension ProfileViewModel { + + enum RelationshipAction: Int, CaseIterable { + case none // set hide from UI + case follow + case pending + case following + case muting + case blocking + case blocked + case edit + case editing + + var option: RelationshipActionOptionSet { + return RelationshipActionOptionSet(rawValue: 1 << rawValue) + } + } + + // construct option set on the enum for safe iterator + struct RelationshipActionOptionSet: OptionSet { + let rawValue: Int + + static let none = RelationshipAction.none.option + static let follow = RelationshipAction.follow.option + static let pending = RelationshipAction.pending.option + static let following = RelationshipAction.following.option + static let muting = RelationshipAction.muting.option + static let blocking = RelationshipAction.blocking.option + static let blocked = RelationshipAction.blocked.option + static let edit = RelationshipAction.edit.option + static let editing = RelationshipAction.editing.option + + static let editOptions: RelationshipActionOptionSet = [.edit, .editing] + + func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { + let set = subtracting(except) + for action in RelationshipAction.allCases.reversed() where set.contains(action.option) { + return action + } + + return nil + } + + var title: String { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return " " + } + switch highPriorityAction { + case .none: return " " + case .follow: return L10n.Common.Controls.Firendship.follow + case .pending: return L10n.Common.Controls.Firendship.pending + case .following: return L10n.Common.Controls.Firendship.following + case .muting: return L10n.Common.Controls.Firendship.muted + case .blocking: return L10n.Common.Controls.Firendship.blocked + case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + } + } + + var backgroundColor: UIColor { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return Asset.Colors.Button.normal.color + } + switch highPriorityAction { + case .none: return Asset.Colors.Button.normal.color + case .follow: return Asset.Colors.Button.normal.color + case .pending: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .muting: return Asset.Colors.Background.alertYellow.color + case .blocking: return Asset.Colors.Background.danger.color + case .blocked: return Asset.Colors.Button.disabled.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + } + } + + } } diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift new file mode 100644 index 000000000..ccd17c612 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -0,0 +1,167 @@ +// +// APIService+Block.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleBlock( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { blockQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.blockUpdateRemote( + blockQueryType: blockQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return block query update type for remote request + func blockUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.BlockQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isBlocking = (mastodonUser.blockingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isBlocking ? .unblock : .block + mastodonUser.update(isBlocking: !isBlocking, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func blockUpdateRemote( + blockQueryType: Mastodon.API.Account.BlockQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.block( + session: session, + domain: domain, + accountID: mastodonUserID, + blockQueryType: blockQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch blockQueryType { + case .block: + break + case .unblock: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift new file mode 100644 index 000000000..f52aae999 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -0,0 +1,187 @@ +// +// APIService+Follow.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + /// Toggle friendship between target MastodonUser and current MastodonUser + /// + /// Following / Following pending <-> Unfollow + /// + /// - Parameters: + /// - mastodonUser: target MastodonUser + /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` + /// - Returns: publisher for `Relationship` + func toggleFollow( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { followQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.followUpdateRemote( + followQueryType: followQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return follow query update type for remote request + func followUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.FollowQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isPending = (mastodonUser.followRequestedBy ?? Set()).contains(_requestMastodonUser) + let isFollowing = (mastodonUser.followingBy ?? Set()).contains(_requestMastodonUser) + + if isFollowing || isPending { + _queryType = .unfollow + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } else { + _queryType = .follow(query: Mastodon.API.Account.FollowQuery()) + if mastodonUser.locked { + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser) + } else { + mastodonUser.update(isFollowing: true, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } + } + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func followUpdateRemote( + followQueryType: Mastodon.API.Account.FollowQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.follow( + session: session, + domain: domain, + accountID: mastodonUserID, + followQueryType: followQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + switch followQueryType { + case .follow: + break + case .unfollow: + break + } + } + }) + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift new file mode 100644 index 000000000..2f9303261 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -0,0 +1,167 @@ +// +// APIService+Mute.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleMute( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { muteQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.muteUpdateRemote( + muteQueryType: muteQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return mute query update type for remote request + func muteUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.MuteQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isMuting = (mastodonUser.mutingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isMuting ? .unmute : .mute + mastodonUser.update(isMuting: !isMuting, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func muteUpdateRemote( + muteQueryType: Mastodon.API.Account.MuteQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.mute( + session: session, + domain: domain, + accountID: mastodonUserID, + muteQueryType: muteQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch muteQueryType { + case .mute: + break + case .unmute: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 7ad5b4745..b0ef29267 100644 --- a/Mastodon/Service/APIService/APIService+Relationship.swift +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -5,7 +5,7 @@ // Created by MainasuK Cirno on 2021-4-1. // -import Foundation +import UIKit import Combine import CoreData import CoreDataStack @@ -19,47 +19,47 @@ extension APIService { accountIDs: [Mastodon.Entity.Account.ID], authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { - fatalError() -// let authorization = authorizationBox.userAuthorization -// let requestMastodonUserID = authorizationBox.userID -// let query = Mastodon.API.Account.AccountStatuseseQuery( -// maxID: maxID, -// sinceID: sinceID, -// excludeReplies: excludeReplies, -// excludeReblogs: excludeReblogs, -// onlyMedia: onlyMedia, -// limit: limit -// ) -// -// return Mastodon.API.Account.statuses( -// session: session, -// domain: domain, -// accountID: accountID, -// query: query, -// authorization: authorization -// ) -// .flatMap { response -> AnyPublisher, Error> in -// return APIService.Persist.persistStatus( -// managedObjectContext: self.backgroundManagedObjectContext, -// domain: domain, -// query: nil, -// response: response, -// persistType: .user, -// requestMastodonUserID: requestMastodonUserID, -// log: OSLog.api -// ) -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.RelationshipQuery( + ids: accountIDs + ) + + return Mastodon.API.Account.relationships( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, ids: accountIDs) + lookUpMastodonUserRequest.fetchLimit = accountIDs.count + let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest) + + for user in lookUpMastodonusers { + guard let entity = response.value.first(where: { $0.id == user.id }) else { continue } + APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 745f47999..fdac2a2a6 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -111,6 +111,7 @@ extension APIService.CoreData { networkDate: Date ) { guard networkDate > user.updatedAt else { return } + guard entity.id != requestMastodonUser.id else { return } // not update relationship for self user.update(isFollowing: entity.following, by: requestMastodonUser) entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index ec8bf9d5e..2c0c39b97 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -67,3 +67,350 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + public enum FollowQueryType { + case follow(query: FollowQuery) + case unfollow + } + + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + followQueryType: FollowQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch followQueryType { + case .follow(let query): + return follow(session: session, domain: domain, accountID: accountID, query: query, authorization: authorization) + case .unfollow: + return unfollow(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func followEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/follow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Follow + /// + /// Follow the given account. Can also be used to update whether to show reblogs or enable notifications. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: FollowQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: followEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowQuery: Codable, PostQuery { + public let reblogs: Bool? + public let notify: Bool? + + public init(reblogs: Bool? = nil , notify: Bool? = nil) { + self.reblogs = reblogs + self.notify = notify + } + } + +} + +extension Mastodon.API.Account { + + static func unfollowEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unfollow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unfollow + /// + /// Unfollow the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unfollow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unfollowEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum BlockQueryType { + case block + case unblock + } + + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + blockQueryType: BlockQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch blockQueryType { + case .block: + return block(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unblock: + return unblock(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/block" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Block + /// + /// Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: blockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unblockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unblock" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unblock + /// + /// Unblock the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unblock( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unblockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum MuteQueryType { + case mute + case unmute + } + + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + muteQueryType: MuteQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch muteQueryType { + case .mute: + return mute(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unmute: + return unmute(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func mutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/mute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Mute + /// + /// Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: mutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unmutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unmute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unmute + /// + /// Unmute the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unmute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unmutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From 3b576badebdda67879e74023b16ffb4c1488d41e Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 18:50:08 +0800 Subject: [PATCH 07/19] feat: add reply entry for profile scene --- .../Diffiable/Section/ComposeStatusSection.swift | 1 + Mastodon/Helper/MastodonField.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../Scene/Compose/ComposeViewModel+Diffable.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 15 ++++++++++++--- .../Scene/Profile/ProfileViewController.swift | 9 +++++++-- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index e9785461a..ebf95a093 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -22,6 +22,7 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { case post + case mention(mastodonUserObjectID: NSManagedObjectID) case reply(repliedToStatusObjectID: NSManagedObjectID) } } diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift index cbe87c09b..e828602e4 100644 --- a/Mastodon/Helper/MastodonField.swift +++ b/Mastodon/Helper/MastodonField.swift @@ -11,7 +11,7 @@ import ActiveLabel enum MastodonField { static func parse(field string: String) -> ParseResult { - let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))") + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)") let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index c316e993e..3e82cd51a 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -538,7 +538,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s.]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index d44892565..496ba2845 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -62,7 +62,7 @@ extension ComposeViewModel { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) - case .post: + case .mention, .post: snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52ca4cc88..3b81a931c 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -71,18 +71,27 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + initialComposeContent: String? = nil ) { self.context = context self.composeKind = composeKind switch composeKind { - case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) - case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init + if case let .mention(mastodonUserObjectID) = composeKind { + context.managedObjectContext.performAndWait { + let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let initialComposeContent = "@" + mastodonUser.acct + " " + self.composeStatusAttribute.composeContent.value = initialComposeContent + } + } + isCustomEmojiComposing .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 57a398b4b..315a49427 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -429,7 +429,12 @@ extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - // TODO: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .mention(mastodonUserObjectID: mastodonUser.objectID) + ) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -641,7 +646,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - + } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) { From 2f89471c7804e1f3d6c59912d563ed225bddf8a1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 19:33:29 +0800 Subject: [PATCH 08/19] feat: add remote profile load logic for profile scene --- Mastodon.xcodeproj/project.pbxproj | 4 ++ ...Provider+StatusTableViewCellDelegate.swift | 4 ++ .../StatusProvider/StatusProviderFacade.swift | 62 +++++++++++++++++ .../Profile/RemoteProfileViewModel.swift | 54 +++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 10 +++ .../TableviewCell/StatusTableViewCell.swift | 13 +++- .../APIService/APIService+Account.swift | 67 ++++++++++++++++++- 7 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 Mastodon/Scene/Profile/RemoteProfileViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2d5d0486b..260366a79 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -266,6 +266,7 @@ DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; + DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -618,6 +619,7 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -1524,6 +1526,7 @@ DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */, DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; @@ -2098,6 +2101,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, + DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index ffaa29b52..f8c99c13f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -24,6 +24,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) } + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) + } + } // MARK: - ActionToolbarContainerDelegate diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 19b0fbf7e..fa9ce3adf 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -62,6 +62,68 @@ extension StatusProviderFacade { } } +extension StatusProviderFacade { + + static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let text, let userInfo): + break + case .mention(let text, let userInfo): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + break + } + } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) { + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + provider.status(for: cell, indexPath: nil) + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status + case .secondary: return status + } + }() + guard let status = _status else { return } + + // cannot continue without meta + guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } + + let userID = mentionMeta.id + + let profileViewModel: ProfileViewModel = { + // check if self + guard userID != activeMastodonAuthenticationBox.userID else { + return MeProfileViewModel(context: provider.context) + } + + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: userID) + let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first + + if let mastodonUser = mastodonUser { + return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + } else { + return RemoteProfileViewModel(context: provider.context, userID: userID) + } + }() + + DispatchQueue.main.async { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } + .store(in: &provider.disposeBag) + } +} + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift new file mode 100644 index 000000000..c480e6fc9 --- /dev/null +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -0,0 +1,54 @@ +// +// RemoteProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import os.log +import Foundation +import CoreDataStack +import MastodonSDK + +final class RemoteProfileViewModel: ProfileViewModel { + + convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) { + self.init(context: context, optionalMastodonUser: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + context.apiService.accountInfo( + domain: domain, + userID: userID, + authorization: authorization + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, userID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetched", ((#file as NSString).lastPathComponent), #line, #function, userID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) + guard let mastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.mastodonUser.value = mastodonUser + } + .store(in: &disposeBag) + + } + + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index b4105a4d2..fc0fda099 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -17,6 +17,7 @@ protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) } final class StatusView: UIView { @@ -402,6 +403,7 @@ extension StatusView { statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + activeTextLabel.delegate = self playerContainerView.delegate = self headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) @@ -475,6 +477,14 @@ extension StatusView { } +// MARK: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.statusView(self, activeLabel: activeLabel, didSelectActiveEntity: entity) + } +} + // MARK: - PlayerContainerViewDelegate extension StatusView: PlayerContainerViewDelegate { func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 9c954e505..e93213fea 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -11,6 +11,7 @@ import AVKit import Combine import CoreData import CoreDataStack +import ActiveLabel protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } @@ -18,18 +19,22 @@ protocol StatusTableViewCellDelegate: class { func parent() -> UIViewController var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } - func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) } @@ -216,6 +221,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index d8ea5cf4f..04908514b 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -10,6 +10,52 @@ import Combine import CommonOSLog import MastodonSDK +extension APIService { + + func accountInfo( + domain: String, + userID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.accountInfo( + session: session, + domain: domain, + userID: userID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + userCache: nil, + networkDate: response.networkDate, + log: log + ) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + extension APIService { func accountVerifyCredentials( @@ -33,12 +79,20 @@ extension APIService { entity: account, userCache: nil, networkDate: response.networkDate, - log: log) + log: log + ) let flag = isCreated ? "+" : "-" os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) } .setFailureType(to: Error.self) - .map { _ in return response } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } .eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -72,7 +126,14 @@ extension APIService { os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) } .setFailureType(to: Error.self) - .map { _ in return response } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } .eraseToAnyPublisher() } .eraseToAnyPublisher() From 28cfe961715b16d36259d16bcdf854ea019ecb66 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 19:40:15 +0800 Subject: [PATCH 09/19] chore: rename Toot -> Status --- ...ashtagTimelineViewController+StatusProvider.swift | 8 ++++---- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 4 ++-- .../HashtagTimelineViewModel+LoadLatestState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadMiddleState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadOldestState.swift | 12 ++++++------ .../HashtagTimeline/HashtagTimelineViewModel.swift | 4 ++-- .../APIService/APIService+HashtagTimeline.swift | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index e4092ce0f..7263e6ab8 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -14,11 +14,11 @@ import CoreDataStack // MARK: - StatusProvider extension HashtagTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -36,7 +36,7 @@ extension HashtagTimelineViewController: StatusProvider { let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.toot)) + promise(.success(timelineIndex?.status)) } default: promise(.success(nil)) @@ -44,7 +44,7 @@ extension HashtagTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index e82bb31ae..c831cf215 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -234,7 +234,7 @@ extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a41568787..9a6102e09 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -56,13 +56,13 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let snapshot = snapshot as NSDiffableDataSourceSnapshot let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Toot + let status = managedObjectContext.object(with: $0) as! Status let isStatusTextSensitive: Bool = { guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } return true }() - return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index b3cb2cc3b..d8e286195 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadLatestState { case .failure(let error): // TODO: handle error viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -83,7 +83,7 @@ extension HashtagTimelineViewModel.LoadLatestState { viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) viewModel.hashtagStatusIDList.removeDuplicates() - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index 3c3b01d87..e971659e1 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -76,7 +76,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { switch completion { case .failure(let error): // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break @@ -105,7 +105,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { viewModel.needLoadMiddleIndex = nil } - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index f503420a7..d464d3a50 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -66,22 +66,22 @@ extension HashtagTimelineViewModel.LoadOldestState { // viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) } - let newStatusIDList = toots.map { $0.id } + let newStatusIDList = statuses.map { $0.id } viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 8f2e07874..e7f167f2a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -24,7 +24,7 @@ final class HashtagTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -70,7 +70,7 @@ final class HashtagTimelineViewModel: NSObject { self.context = context self.hashTag = hashTag self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest + let fetchRequest = Status.sortedFetchRequest fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift index d3e9d6208..69c2c7486 100644 --- a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, hashtag: String, authorizationBox: AuthenticationService.MastodonAuthenticationBox From 824d214ce74d0e444b8fae8bdf13770a416f303c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 16:42:45 +0800 Subject: [PATCH 10/19] chore: update color asset --- Mastodon/Generated/Assets.swift | 20 +++------- .../Button/disabled.colorset/Contents.json | 24 ++++++++++-- .../Button/inactive.colorset/Contents.json | 38 +++++++++++++++++++ .../Button/normal.colorset/Contents.json | 6 +-- .../backgroundLight.colorset/Contents.json | 20 ---------- .../Colors/brand.blue.colorset/Contents.json | 38 +++++++++++++++++++ .../buttonDefault.colorset/Contents.json | 20 ---------- .../buttonDisabled.colorset/Contents.json | 20 ---------- .../buttonInactive.colorset/Contents.json | 20 ---------- .../Colors/disabled.colorset/Contents.json | 38 +++++++++++++++++++ .../Colors/inactive.colorset/Contents.json | 38 +++++++++++++++++++ .../lightAlertYellow.colorset/Contents.json | 20 ---------- .../lightBackground.colorset/Contents.json | 20 ---------- .../lightBrandBlue.colorset/Contents.json | 20 ---------- .../lightDarkGray.colorset/Contents.json | 20 ---------- .../lightDisabled.colorset/Contents.json | 20 ---------- .../lightInactive.colorset/Contents.json | 20 ---------- .../lightSecondaryText.colorset/Contents.json | 20 ---------- .../lightSuccessGreen.colorset/Contents.json | 20 ---------- .../Colors/lightWhite.colorset/Contents.json | 20 ---------- .../Contents.json | 0 .../system.green.colorset/Contents.json | 20 ---------- .../HomeTimelineNavigationBarTitleView.swift | 2 +- .../MastodonConfirmEmailViewController.swift | 4 +- .../TableViewCell/PickServerCell.swift | 24 ++++++------ .../TableViewCell/PickServerSearchCell.swift | 10 ++--- .../View/PickServerCategoryView.swift | 2 +- .../Scene/Search/SearchViewController.swift | 2 +- .../Content/NavigationBarProgressView.swift | 2 +- .../TimelineLoaderTableViewCell.swift | 2 +- .../View/ToolBar/ActionToolBarContainer.swift | 2 +- 31 files changed, 206 insertions(+), 326 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/{Background/success.colorset => success.green.colorset}/Contents.json (100%) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 0abcc2341..71034c1d8 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -45,7 +45,6 @@ internal enum Asset { internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") - internal static let success = ColorAsset(name: "Colors/Background/success") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") @@ -54,6 +53,7 @@ internal enum Asset { internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") + internal static let inactive = ColorAsset(name: "Colors/Button/inactive") internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { @@ -73,21 +73,11 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } - internal static let backgroundLight = ColorAsset(name: "Colors/backgroundLight") - internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault") - internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled") - internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive") + internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let danger = ColorAsset(name: "Colors/danger") - internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") - internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") - internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") - internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray") - internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled") - internal static let lightInactive = ColorAsset(name: "Colors/lightInactive") - internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") - internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") - internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") - internal static let systemGreen = ColorAsset(name: "Colors/system.green") + internal static let disabled = ColorAsset(name: "Colors/disabled") + internal static let inactive = ColorAsset(name: "Colors/inactive") + internal static let successGreen = ColorAsset(name: "Colors/success.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Connectivity { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index bca754614..f2e6f489e 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "140", - "green" : "130", - "red" : "110" + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json new file mode 100644 index 000000000..9fbab2202 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json index cd9b7c5ba..869ed278a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "217", - "green" : "144", - "red" : "43" + "blue" : "0xD9", + "green" : "0x90", + "red" : "0x2B" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json deleted file mode 100644 index 0e4687fb4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json new file mode 100644 index 000000000..a85c0e379 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD9", + "green" : "0x90", + "red" : "0x2B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE4", + "green" : "0x9D", + "red" : "0x3A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json deleted file mode 100644 index 2e1ce5f3a..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json deleted file mode 100644 index 78cde95fb..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json deleted file mode 100644 index 69dc63851..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json new file mode 100644 index 000000000..303021b9f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "200", + "green" : "174", + "red" : "155" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x5D", + "red" : "0x4F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json new file mode 100644 index 000000000..ea5d9760a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8C", + "green" : "0x82", + "red" : "0x6E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x5D", + "red" : "0x4F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json deleted file mode 100644 index 0e29336a8..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "red" : "0.792", - "blue" : "0.016", - "green" : "0.561", - "alpha" : "1.000" - } - } - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json deleted file mode 100644 index 0e4687fb4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json deleted file mode 100644 index d853a71aa..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "217", - "green" : "144", - "red" : "43" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json deleted file mode 100644 index e6461f1d3..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.137", - "red" : "0.122" - } - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json deleted file mode 100644 index 78cde95fb..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json deleted file mode 100644 index 69dc63851..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json deleted file mode 100644 index ac36bf1f4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "components" : { - "blue" : "0.263", - "green" : "0.235", - "alpha" : "0.600", - "red" : "0.235" - }, - "color-space" : "srgb" - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json deleted file mode 100644 index 8ef654ce0..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "green" : "0.741", - "red" : "0.475", - "blue" : "0.604" - } - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json deleted file mode 100644 index 5147016be..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal", - "color" : { - "components" : { - "red" : "0.996", - "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000" - }, - "color-space" : "srgb" - } - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json deleted file mode 100644 index 8716dcb74..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.604", - "green" : "0.741", - "red" : "0.475" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 604c0915d..242715028 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -120,7 +120,7 @@ extension HomeTimelineNavigationBarTitleView { configureButton( title: L10n.Scene.HomeTimeline.NavigationBarState.published, textColor: .white, - backgroundColor: Asset.Colors.Background.success.color + backgroundColor: Asset.Colors.successGreen.color ) button.isHidden = false diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 338be6ab6..9d15c8476 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -40,7 +40,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let openEmailButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal) + button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) button.setTitleColor(.white, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal) button.layer.masksToBounds = true @@ -53,7 +53,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let dontReceiveButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15)) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal) button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside) return button diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 5ff83cc70..bf2299122 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -27,7 +27,7 @@ class PickServerCell: UITableViewCell { let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.systemBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -35,7 +35,7 @@ class PickServerCell: UITableViewCell { let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label @@ -44,7 +44,7 @@ class PickServerCell: UITableViewCell { let checkbox: UIImageView = { let imageView = UIImageView() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - imageView.tintColor = Asset.Colors.lightSecondaryText.color + imageView.tintColor = Asset.Colors.Label.secondary.color imageView.contentMode = .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints = false return imageView @@ -54,7 +54,7 @@ class PickServerCell: UITableViewCell { let label = UILabel() label.font = .preferredFont(forTextStyle: .subheadline) label.numberOfLines = 0 - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label @@ -90,7 +90,7 @@ class PickServerCell: UITableViewCell { let button = UIButton(type: .custom) button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -98,14 +98,14 @@ class PickServerCell: UITableViewCell { let seperator: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() let langValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -115,7 +115,7 @@ class PickServerCell: UITableViewCell { let usersValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -125,7 +125,7 @@ class PickServerCell: UITableViewCell { let categoryValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -135,7 +135,7 @@ class PickServerCell: UITableViewCell { let langTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.language label.textAlignment = .center @@ -146,7 +146,7 @@ class PickServerCell: UITableViewCell { let usersTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.users label.textAlignment = .center @@ -157,7 +157,7 @@ class PickServerCell: UITableViewCell { let categoryTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.category label.textAlignment = .center diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index f35f586a4..b708313ac 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -17,7 +17,7 @@ class PickServerSearchCell: UITableViewCell { private var bgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.systemBackground.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.maskedCorners = [ .layerMinXMinYCorner, @@ -30,7 +30,7 @@ class PickServerSearchCell: UITableViewCell { private var textFieldBgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6) + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color.withAlphaComponent(0.6) view.translatesAutoresizingMaskIntoConstraints = false view.layer.masksToBounds = true view.layer.cornerRadius = 6 @@ -42,13 +42,13 @@ class PickServerSearchCell: UITableViewCell { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false textField.font = .preferredFont(forTextStyle: .headline) - textField.tintColor = Asset.Colors.lightDarkGray.color - textField.textColor = Asset.Colors.lightDarkGray.color + textField.tintColor = Asset.Colors.Label.primary.color + textField.textColor = Asset.Colors.Label.primary.color textField.adjustsFontForContentSizeCategory = true textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline), - .foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)]) + .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)]) textField.clearButtonMode = .whileEditing textField.autocapitalizationType = .none textField.autocorrectionType = .no diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 7ea147e0a..16d5a9fcc 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -48,7 +48,7 @@ extension PickServerCategoryView { addSubview(bgView) addSubview(titleLabel) - bgView.backgroundColor = Asset.Colors.lightWhite.color + bgView.backgroundColor = Asset.Colors.Background.systemBackground.color NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f76f596c0..f6b4e4341 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -19,7 +19,7 @@ final class SearchViewController: UIViewController, NeedsDependency { let searchBar: UISearchBar = { let searchBar = UISearchBar() searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder - searchBar.tintColor = Asset.Colors.buttonDefault.color + searchBar.tintColor = Asset.Colors.brandBlue.color searchBar.translatesAutoresizingMaskIntoConstraints = false let micImage = UIImage(systemName: "mic.fill") searchBar.setImage(micImage, for: .bookmark, state: .normal) diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift index d011ca897..3cb1d1d9d 100644 --- a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift +++ b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift @@ -13,7 +13,7 @@ class NavigationBarProgressView: UIView { let sliderView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.buttonDefault.color + view.backgroundColor = Asset.Colors.brandBlue.color view.translatesAutoresizingMaskIntoConstraints = false return view }() diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index fe54380ed..38bf7ef78 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -67,7 +67,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { func stopAnimating() { activityIndicatorView.stopAnimating() self.loadMoreButton.isEnabled = true - self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color + self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color self.loadMoreLabel.text = "" } diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index daaa607d9..b777207d2 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -163,7 +163,7 @@ extension ActionToolbarContainer { } private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { - let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color + let tintColor = isHighlight ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color reblogButton.tintColor = tintColor reblogButton.setTitleColor(tintColor, for: .normal) reblogButton.setTitleColor(tintColor, for: .highlighted) From 9612cc3902b9327ebb930dcdbea3db778cbf4456 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 16:43:08 +0800 Subject: [PATCH 11/19] feat: handle blocking and blocked state for profile --- Localization/README.md | 14 +- Localization/app.json | 6 + Mastodon.xcodeproj/project.pbxproj | 10 +- Mastodon/Coordinator/SceneCoordinator.swift | 2 +- Mastodon/Diffiable/Item/Item.swift | 44 ++++++- .../Section/CategoryPickerSection.swift | 12 +- .../Diffiable/Section/StatusSection.swift | 13 ++ Mastodon/Extension/ActiveLabel.swift | 9 +- Mastodon/Generated/Strings.swift | 10 ++ .../StatusProvider/StatusProviderFacade.swift | 4 +- .../Resources/en.lproj/Localizable.strings | 7 + .../Scene/Profile/ProfileViewController.swift | 69 +++------- .../Timeline/UserTimelineViewController.swift | 25 +++- .../UserTimelineViewModel+State.swift | 66 ++-------- .../Timeline/UserTimelineViewModel.swift | 100 ++++++++------ .../View/Content/TimelineHeaderView.swift | 122 ++++++++++++++++++ .../TimelineHeaderTableViewCell.swift | 42 ++++++ .../Service/APIService/APIService+Block.swift | 5 +- .../APIService/APIService+Follow.swift | 3 +- .../Service/APIService/APIService+Mute.swift | 4 +- 20 files changed, 390 insertions(+), 177 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift diff --git a/Localization/README.md b/Localization/README.md index 1e6975f8b..b6baf1788 100644 --- a/Localization/README.md +++ b/Localization/README.md @@ -5,4 +5,16 @@ Mastodon localization template file ## How to contribute? -TBD \ No newline at end of file +TBD + +## How to maintains + +```zsh +// enter workdir +cd Mastodon +// edit i18n json +open ./Localization/app.json +// update resource +update_localization.sh + +``` \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 9eaba58f4..ce963936f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -86,6 +86,12 @@ "loader": { "load_missing_posts": "Load missing posts", "loading_missing_posts": "Loading missing posts..." + }, + "header": { + "no_status_found": "No Status Found", + "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", + "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", + "suspended_warning": "This account is suspended." } } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 260366a79..25ad03f3a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -244,7 +244,6 @@ DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; - DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; @@ -295,6 +294,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -647,6 +648,8 @@ DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -780,6 +783,7 @@ 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */, + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, ); path = Content; sourceTree = ""; @@ -982,6 +986,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; @@ -2022,6 +2027,7 @@ DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, @@ -2088,6 +2094,7 @@ DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, @@ -2153,7 +2160,6 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, - DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4b6eed7ba..67d5142c8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -116,7 +116,7 @@ extension SceneCoordinator { if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { switch viewController { case is ProfileViewController: - let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil) + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem default: diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index cd07c8836..0a27f1871 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -22,6 +22,8 @@ enum Item { case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) case publicMiddleLoader(statusID: String) case bottomLoader + + case emptyStateHeader(attribute: EmptyStateHeaderAttribute) } protocol StatusContentWarningAttribute { @@ -56,6 +58,30 @@ extension Item { } } } + + class EmptyStateHeaderAttribute: Hashable { + let id = UUID() + let reason: Reason + + enum Reason { + case noStatusFound + case blocking + case blocked + case suspended + } + + init(reason: Reason) { + self.reason = reason + } + + static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool { + return lhs.reason == rhs.reason + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } } extension Item: Equatable { @@ -65,12 +91,14 @@ extension Item: Equatable { return objectIDLeft == objectIDRight case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight - case (.bottomLoader, .bottomLoader): - return true - case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): - return upperLeft == upperRight case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): return upperLeft == upperRight + case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): + return upperLeft == upperRight + case (.bottomLoader, .bottomLoader): + return true + case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): + return attributeLeft == attributeRight default: return false } @@ -84,14 +112,16 @@ extension Item: Hashable { hasher.combine(objectID) case .status(let objectID, _): hasher.combine(objectID) - case .publicMiddleLoader(let upper): - hasher.combine(String(describing: Item.publicMiddleLoader.self)) - hasher.combine(upper) case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): hasher.combine(String(describing: Item.homeMiddleLoader.self)) hasher.combine(upper) + case .publicMiddleLoader(let upper): + hasher.combine(String(describing: Item.publicMiddleLoader.self)) + hasher.combine(upper) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) + case .emptyStateHeader(let attribute): + hasher.combine(attribute) } } } diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 2164d9ebc..52443a13d 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -27,16 +27,16 @@ extension CategoryPickerSection { cell.categoryView.titleLabel.text = item.title cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in if cell.isSelected { - cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) + cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) if case .all = item { - cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color + cell.categoryView.titleLabel.textColor = .white } } else { - cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) + cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) if case .all = item { - cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color + cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color } } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5e891e13a..fe720e0f0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -79,12 +79,17 @@ extension StatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell + case .emptyStateHeader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell + StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) + return cell } } } } extension StatusSection { + static func configure( cell: StatusTableViewCell, dependency: NeedsDependency, @@ -473,6 +478,14 @@ extension StatusSection { snapshot.appendItems(pollItems, toSection: .main) cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } + + static func configureEmptyStateHeader( + cell: TimelineHeaderTableViewCell, + attribute: Item.EmptyStateHeaderAttribute + ) { + cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage + cell.timelineHeaderView.messageLabel.text = attribute.reason.message + } } extension StatusSection { diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 614735ad1..66452e23e 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -64,11 +64,8 @@ extension ActiveLabel { /// account field func configure(field: String) { activeEntities.removeAll() - if let parseResult = try? MastodonField.parse(field: field) { - text = parseResult.value - activeEntities = parseResult.activeEntities - } else { - text = "" - } + let parseResult = MastodonField.parse(field: field) + text = parseResult.value + activeEntities = parseResult.activeEntities } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 53ef603e2..7dd334d5a 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -172,6 +172,16 @@ internal enum L10n { } } internal enum Timeline { + internal enum Header { + /// You can’t view Artbot’s profile\n until they unblock you. + internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") + /// You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them. + internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") + /// No Status Found + internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") + /// This account is suspended. + internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + } internal enum Loader { /// Loading missing posts... internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index fa9ce3adf..37db1d853 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -66,9 +66,9 @@ extension StatusProviderFacade { static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { switch entity.type { - case .hashtag(let text, let userInfo): + case .hashtag: break - case .mention(let text, let userInfo): + case .mention(let text, _): coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) case .url(_, _, let url, _): guard let url = URL(string: url) else { return } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 4a9b7bd30..0efa7376e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -54,6 +54,13 @@ Please check your internet connection."; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile + until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile + until you unblock them. +Your account looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 315a49427..1fe7a908a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -140,63 +140,17 @@ extension ProfileViewController { } .store(in: &disposeBag) - -// Publishers.CombineLatest4( -// viewModel.muted.eraseToAnyPublisher(), -// viewModel.blocked.eraseToAnyPublisher(), -// viewModel.twitterUser.eraseToAnyPublisher(), -// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in -// guard let self = self else { return } -// guard let twitterUser = twitterUser, -// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox, -// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else { -// self.navigationItem.rightBarButtonItems = [] -// return -// } -// -// if #available(iOS 14.0, *) { -// self.moreMenuBarButtonItem.target = nil -// self.moreMenuBarButtonItem.action = nil -// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser( -// twitterUser: twitterUser, -// muted: muted, -// blocked: blocked, -// dependency: self -// ) -// } else { -// // no menu supports for early version -// self.moreMenuBarButtonItem.target = self -// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:)) -// } -// -// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem] -// if muted { -// rightBarButtonItems.append(self.unmuteMenuBarButtonItem) -// } -// -// self.navigationItem.rightBarButtonItems = rightBarButtonItems -// } -// .store(in: &disposeBag) - overlayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) -// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self) - let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) - viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag) - + bind(userTimelineViewModel: postsUserTimelineViewModel) + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) - viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag) - + bind(userTimelineViewModel: repliesUserTimelineViewModel) + let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) - viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag) + bind(userTimelineViewModel: mediaUserTimelineViewModel) profileSegmentedViewController.pagingViewController.viewModel = { let profilePagingViewModel = ProfilePagingViewModel( @@ -275,7 +229,7 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] name in guard let self = self else { return } - self.title = name + self.navigationItem.title = name } .store(in: &disposeBag) @@ -425,6 +379,17 @@ extension ProfileViewController { } +extension ProfileViewController { + + private func bind(userTimelineViewModel: UserTimelineViewModel) { + viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) + viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) + viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + } + +} + extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 88134f1e1..442f57cce 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -27,6 +27,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency { let tableView = UITableView() tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear @@ -100,9 +101,29 @@ extension UserTimelineViewController { // MARK: - UITableViewDelegate extension UserTimelineViewController: UITableViewDelegate { - // TODO: cache cell height func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 200 + guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return 200 + } + } + // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + + return ceil(frame.height) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + let key = item.hashValue + let frame = cell.frame + viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..0caa4a20c 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -31,8 +31,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type: return viewModel.userID.value != nil - case is Suspended.Type: - return true default: return false } @@ -48,10 +46,6 @@ extension UserTimelineViewModel.State { return true case is NoMore.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } @@ -116,8 +110,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type, is LoadingMore.Type: return true - case is Suspended.Type: - return true default: return false } @@ -129,8 +121,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type, is LoadingMore.Type: return true - case is Suspended.Type: - return true default: return false } @@ -146,10 +136,6 @@ extension UserTimelineViewModel.State { return true case is NoMore.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } @@ -188,7 +174,12 @@ extension UserTimelineViewModel.State { ) .receive(on: DispatchQueue.main) .sink { completion in - + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + break + } } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -210,53 +201,22 @@ extension UserTimelineViewModel.State { .store(in: &viewModel.disposeBag) } } - - class NotAuthorized: UserTimelineViewModel.State { - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Reloading.Type: - return true - case is Suspended.Type: - return true - default: - return false - } - } - - } - - class Blocked: UserTimelineViewModel.State { - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Reloading.Type: - return true - case is Suspended.Type: - return true - default: - return false - } - } - - } - - class Suspended: UserTimelineViewModel.State { - - } class NoMore: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Reloading.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + + // trigger data source update + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index a550dc829..2276db5fe 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject { let userID: CurrentValueSubject let queryFilter: CurrentValueSubject let statusFetchedResultsController: StatusFetchedResultsController + var cellFrameCache = NSCache() + + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) // output var diffableDataSource: UITableViewDiffableDataSource? @@ -34,9 +38,6 @@ class UserTimelineViewModel: NSObject { State.Fail(viewModel: self), State.Idle(viewModel: self), State.LoadingMore(viewModel: self), - State.NotAuthorized(viewModel: self), - State.Blocked(viewModel: self), - State.Suspended(viewModel: self), State.NoMore(viewModel: self), ]) stateMachine.enter(State.Initial.self) @@ -59,46 +60,64 @@ class UserTimelineViewModel: NSObject { .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - - statusFetchedResultsController.objectIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - // var isPermissionDenied = false - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - - var items: [Item] = [] - for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - items.append(.status(objectID: objectID, attribute: attribute)) - } - snapshot.appendItems(items, toSection: .main) - - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - // TODO: handle other states - default: - break - } - } - + Publishers.CombineLatest3( + statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), + isBlocking.eraseToAnyPublisher(), + isBlockedBy.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] objectIDs, isBlocking, isBlockedBy in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { // not animate when empty items fix loader first appear layout issue diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) } - .store(in: &disposeBag) + + guard !isBlocking else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking))], toSection: .main) + return + } + + guard !isBlockedBy else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked))], toSection: .main) + return + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + // TODO: handle other states + default: + break + } + } + + + } + .store(in: &disposeBag) } deinit { @@ -125,3 +144,4 @@ extension UserTimelineViewModel { } } + diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift new file mode 100644 index 000000000..e253b3ca7 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -0,0 +1,122 @@ +// +// TimelineHeaderView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +final class TimelineHeaderView: UIView { + + let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderView { + + private func _init() { + backgroundColor = .clear + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: topAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(iconImageView) + containerStackView.addArrangedSubview(messageLabel) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalToConstant: 100).priority(.defaultHigh), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), + ]) + } + +} + +extension Item.EmptyStateHeaderAttribute.Reason { + var iconImage: UIImage? { + switch self { + case .noStatusFound, .blocking, .blocked, .suspended: + return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! + } + } + + var message: String { + switch self { + case .noStatusFound: + return L10n.Common.Controls.Timeline.Header.noStatusFound + case .blocking: + return L10n.Common.Controls.Timeline.Header.blockingWarning + case .blocked: + return L10n.Common.Controls.Timeline.Header.blockedWarning + case .suspended: + return L10n.Common.Controls.Timeline.Header.suspendedWarning + } + } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct TimelineHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let headerView = TimelineHeaderView() + headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking.iconImage + headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking.message + return headerView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift new file mode 100644 index 000000000..ba1b6b103 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift @@ -0,0 +1,42 @@ +// +// TimelineHeaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit + +final class TimelineHeaderTableViewCell: UITableViewCell { + + let timelineHeaderView = TimelineHeaderView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + timelineHeaderView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(timelineHeaderView) + NSLayoutConstraint.activate([ + timelineHeaderView.topAnchor.constraint(equalTo: contentView.topAnchor), + timelineHeaderView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + timelineHeaderView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + timelineHeaderView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift index ccd17c612..124b65155 100644 --- a/Mastodon/Service/APIService/APIService+Block.swift +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -145,11 +145,12 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error - break + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] block update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: // TODO: update relationship switch blockQueryType { diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index f52aae999..f2c57db57 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -167,10 +167,11 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) break case .finished: switch followQueryType { diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift index 2f9303261..9d992ab6a 100644 --- a/Mastodon/Service/APIService/APIService+Mute.swift +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -145,11 +145,11 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error - break + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] Mute update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // TODO: update relationship switch muteQueryType { From 021d6036cd5c632c89df2a4f92d0d442fb7f8986 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:12:25 +0800 Subject: [PATCH 12/19] chore: update dark mode color for background. Make blocking high priority then blocked --- .../Contents.json | 12 +++--- .../Contents.json | 6 +-- .../system.background.colorset/Contents.json | 10 ++--- .../Contents.json | 6 +-- .../Label/secondary.colorset/Contents.json | 6 +-- .../ProfileRelationshipActionButton.swift | 8 +++- .../Scene/Profile/ProfileViewController.swift | 37 ------------------- Mastodon/Scene/Profile/ProfileViewModel.swift | 8 ++-- 8 files changed, 31 insertions(+), 62 deletions(-) diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index abe46b9aa..55f84c267 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", - "green" : "255", - "red" : "255" + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xFE" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 91dac809a..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.176", - "red" : "0.161" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index d8f32572f..55f84c267 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", + "blue" : "0xFE", "green" : "0xFF", - "red" : "0xFF" + "red" : "0xFE" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index d47050048..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json index 8953c8fb0..70b1446d0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "67", - "green" : "60", - "red" : "60" + "blue" : "0x43", + "green" : "0x3C", + "red" : "0x3C" } }, "idiom" : "universal" diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index b098c1ec1..0f6a804b5 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -34,7 +34,13 @@ extension ProfileRelationshipActionButton { setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled) + + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { + isEnabled = false + } else { + isEnabled = true + } } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 1fe7a908a..59cf4809f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -419,43 +419,6 @@ extension ProfileViewController { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) // coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) // } -// -// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// guard let twitterUser = viewModel.twitterUser.value else { -// assertionFailure() -// return -// } -// -// UserProviderFacade.toggleMuteUser( -// context: context, -// twitterUser: twitterUser, -// muted: viewModel.muted.value -// ) -// .sink { _ in -// // do nothing -// } receiveValue: { _ in -// // do nothing -// } -// .store(in: &disposeBag) -// } -// -// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// guard let twitterUser = viewModel.twitterUser.value else { -// assertionFailure() -// return -// } -// -// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser( -// twitterUser: twitterUser, -// muted: viewModel.muted.value, -// blocked: viewModel.blocked.value, -// sender: sender, -// dependency: self -// ) -// present(moreMenuAlertController, animated: true, completion: nil) -// } } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 5df8952b8..057e18030 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -307,8 +307,8 @@ extension ProfileViewModel { case pending case following case muting - case blocking case blocked + case blocking case edit case editing @@ -326,8 +326,8 @@ extension ProfileViewModel { static let pending = RelationshipAction.pending.option static let following = RelationshipAction.following.option static let muting = RelationshipAction.muting.option - static let blocking = RelationshipAction.blocking.option static let blocked = RelationshipAction.blocked.option + static let blocking = RelationshipAction.blocking.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -353,8 +353,8 @@ extension ProfileViewModel { case .pending: return L10n.Common.Controls.Firendship.pending case .following: return L10n.Common.Controls.Firendship.following case .muting: return L10n.Common.Controls.Firendship.muted - case .blocking: return L10n.Common.Controls.Firendship.blocked case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .blocking: return L10n.Common.Controls.Firendship.blocked case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done } @@ -371,8 +371,8 @@ extension ProfileViewModel { case .pending: return Asset.Colors.Button.normal.color case .following: return Asset.Colors.Button.normal.color case .muting: return Asset.Colors.Background.alertYellow.color - case .blocking: return Asset.Colors.Background.danger.color case .blocked: return Asset.Colors.Button.disabled.color + case .blocking: return Asset.Colors.Background.danger.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color } From e4199df42cf40281b6ed97f61cc2531c31cd382b Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:18:06 +0800 Subject: [PATCH 13/19] feat: set background color for banner image view --- .../mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist | 2 +- Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..c1f4dcf1f 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 12 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index a6b1f275c..bf292ac45 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -32,6 +32,7 @@ final class ProfileHeaderView: UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor imageView.layer.masksToBounds = true // #if DEBUG // imageView.image = .placeholder(color: .red) From 0822b222fc0cdf03d78b05791280bcc28336230c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:48:20 +0800 Subject: [PATCH 14/19] fix: debug running may assert fail issue --- Mastodon/Extension/UINavigationController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Mastodon/Extension/UINavigationController.swift b/Mastodon/Extension/UINavigationController.swift index 54583e50a..9a9c44ab3 100644 --- a/Mastodon/Extension/UINavigationController.swift +++ b/Mastodon/Extension/UINavigationController.swift @@ -11,7 +11,6 @@ import UIKit // SeeAlso: `AdaptiveStatusBarStyleNavigationController` extension UINavigationController { open override var childForStatusBarStyle: UIViewController? { - assertionFailure("Won't enter here") return visibleViewController } } From 1d6345b12b001fde611e89518456394bb0f546ee Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:52:37 +0800 Subject: [PATCH 15/19] fix: text checker not learn reply post mention word issue --- Mastodon/Scene/Compose/ComposeViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 3b81a931c..01d015682 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -71,8 +71,7 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind, - initialComposeContent: String? = nil + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind @@ -87,8 +86,9 @@ final class ComposeViewModel { if case let .mention(mastodonUserObjectID) = composeKind { context.managedObjectContext.performAndWait { let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let initialComposeContent = "@" + mastodonUser.acct + " " - self.composeStatusAttribute.composeContent.value = initialComposeContent + let initialComposeContent = "@" + mastodonUser.acct + UITextChecker.learnWord(initialComposeContent) + self.composeStatusAttribute.composeContent.value = initialComposeContent + " " } } From a61e662f3891ed2f546639b6c628ef59e30bf4da Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 13:57:03 +0800 Subject: [PATCH 16/19] fix: resolve requested changes --- Mastodon.xcodeproj/project.pbxproj | 4 + .../StatusWithGapFetchResultController.swift | 85 +++++++++++++++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 17 ++-- ...imelineViewController+StatusProvider.swift | 6 +- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 13 +-- .../Welcome/WelcomeViewController.swift | 2 +- .../UserTimelineViewModel+State.swift | 7 ++ .../API/Mastodon+API+Favorites.swift | 2 +- 9 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 824b60ad1..9af5bd234 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -356,6 +357,7 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1659,6 +1661,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2137,6 +2140,7 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift new file mode 100644 index 000000000..f392c893d --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift @@ -0,0 +1,85 @@ +// +// StatusWithGapFetchResultController.swift +// Mastodon +// +// Created by BradGao on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +class StatusWithGapFetchResultController: NSObject { + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + var needLoadMiddleIndex: Int? = nil + + // output + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Status.predicate(domain: domain ?? "", ids: ids), + additionalTweetPredicate + ]) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = statusIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.objectIDs.value = items + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52a7bf2f4..03211e3d3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,7 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) - var injectedContent: String? = nil + // In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users. + var preInsertedContent: String? = nil // custom emojis var customEmojiViewModelSubscription: AnyCancellable? @@ -74,11 +75,11 @@ final class ComposeViewModel { init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind, - injectedContent: String? = nil + preInsertedContent: String? = nil ) { self.context = context self.composeKind = composeKind - self.injectedContent = injectedContent + self.preInsertedContent = preInsertedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -204,9 +205,9 @@ final class ComposeViewModel { if content.isEmpty { return true } - // if injectedContent plus a space is equal to the content, simply dismiss the modal - if let injectedContent = self?.injectedContent { - return content == (injectedContent + " ") + // if preInsertedContent plus a space is equal to the content, simply dismiss the modal + if let preInsertedContent = self?.preInsertedContent { + return content == (preInsertedContent + " ") } return false } @@ -316,9 +317,9 @@ final class ComposeViewModel { }) .store(in: &disposeBag) - if let injectedContent = injectedContent { + if let preInsertedContent = preInsertedContent { // add a space after the injected text - composeStatusAttribute.composeContent.send(injectedContent + " ") + composeStatusAttribute.composeContent.send(preInsertedContent + " ") } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index 7263e6ab8..23068b7bc 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -32,11 +32,11 @@ extension HashtagTimelineViewController: StatusProvider { } switch item { - case .homeTimelineIndex(let objectID, _): + case .status(let objectID, _): let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.status)) + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) } default: promise(.success(nil)) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index c831cf215..1dbb0323c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -165,7 +165,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)") coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 9a6102e09..a0bf5d82d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -55,14 +55,17 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let oldSnapshot = diffableDataSource.snapshot() let snapshot = snapshot as NSDiffableDataSourceSnapshot + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + let statusItemList: [Item] = snapshot.itemIdentifiers.map { let status = managedObjectContext.object(with: $0) as! Status - let isStatusTextSensitive: Bool = { - guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() + return Item.status(objectID: $0, attribute: attribute) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 838f1327a..c647d04ca 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -222,6 +222,6 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { // make underneath view controller alive to fix layout issue due to view life cycle - return .overFullScreen + return .fullScreen } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..cbd87e335 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -258,5 +258,12 @@ extension UserTimelineViewModel.State { return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index cb01e83eb..64598bc14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -121,7 +121,7 @@ extension Mastodon.API.Favorites { case destroy } - public struct ListQuery: GetQuery,PagedQueryType { + public struct ListQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? From 2d65bda7fe4dec0a7b31b0a0bb88a3f1a88902d4 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 16:37:05 +0800 Subject: [PATCH 17/19] chore: migrate HashtagViewModel to use `StatusFetchedResultsController` --- Mastodon.xcodeproj/project.pbxproj | 4 - .../StatusFetchedResultsController.swift | 11 +-- .../StatusWithGapFetchResultController.swift | 85 ------------------- .../HashtagTimelineViewController.swift | 2 + .../HashtagTimelineViewModel+Diffable.swift | 23 ++--- ...tagTimelineViewModel+LoadLatestState.swift | 12 +-- ...tagTimelineViewModel+LoadMiddleState.swift | 16 ++-- ...tagTimelineViewModel+LoadOldestState.swift | 12 +-- .../HashtagTimelineViewModel.swift | 38 ++------- 9 files changed, 42 insertions(+), 161 deletions(-) delete mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9af5bd234..824b60ad1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -357,7 +356,6 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1661,7 +1659,6 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2140,7 +2137,6 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index a61429ab8..dd373b29f 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -25,7 +25,7 @@ final class StatusFetchedResultsController: NSObject { // output let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" self.fetchedResultsController = { let fetchRequest = Status.sortedFetchRequest @@ -52,10 +52,11 @@ final class StatusFetchedResultsController: NSObject { .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) + var predicates = [Status.predicate(domain: domain ?? "", ids: ids)] + if let additionalPredicate = additionalTweetPredicate { + predicates.append(additionalPredicate) + } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift deleted file mode 100644 index f392c893d..000000000 --- a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StatusWithGapFetchResultController.swift -// Mastodon -// -// Created by BradGao on 2021/4/7. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -class StatusWithGapFetchResultController: NSObject { - var disposeBag = Set() - - let fetchedResultsController: NSFetchedResultsController - - // input - let domain = CurrentValueSubject(nil) - let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) - - var needLoadMiddleIndex: Int? = nil - - // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { - self.domain.value = domain ?? "" - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - fetchedResultsController.delegate = self - - Publishers.CombineLatest( - self.domain.removeDuplicates().eraseToAnyPublisher(), - self.statusIDs.removeDuplicates().eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] domain, ids in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } -} - -// MARK: - NSFetchedResultsControllerDelegate -extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let indexes = statusIDs.value - let objects = fetchedResultsController.fetchedObjects ?? [] - - let items: [NSManagedObjectID] = objects - .compactMap { object in - indexes.firstIndex(of: object.id).map { index in (index, object) } - } - .sorted { $0.0 < $1.0 } - .map { $0.1.objectID } - self.objectIDs.value = items - } -} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 1dbb0323c..9d638e6c6 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -107,6 +107,8 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) + + } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a0bf5d82d..26f32a33c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -33,14 +33,9 @@ extension HashtagTimelineViewModel { } } -// MARK: - NSFetchedResultsControllerDelegate -extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { - - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { +// MARK: - Compare old & new snapshots and generate new items +extension HashtagTimelineViewModel { + func generateStatusItems(newObjectIDs: [NSManagedObjectID]) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let tableView = self.tableView else { return } @@ -48,12 +43,12 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { guard let diffableDataSource = self.diffableDataSource else { return } - let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext let oldSnapshot = diffableDataSource.snapshot() - let snapshot = snapshot as NSDiffableDataSourceSnapshot +// let snapshot = snapshot as NSDiffableDataSourceSnapshot var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { @@ -61,9 +56,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { oldSnapshotAttributeDict[objectID] = attribute } - let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Status - + let statusItemList: [Item] = newObjectIDs.map { let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() return Item.status(objectID: $0, attribute: attribute) } @@ -75,7 +68,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { // If yes, insert a `middleLoader` at the index var newItems = statusItemList - newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) newSnapshot.appendItems(newItems, toSection: .main) } else { newSnapshot.appendItems(statusItemList, toSection: .main) @@ -112,6 +105,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil } let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! @@ -127,5 +121,4 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { } return nil } - } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index d8e286195..e772e8ea0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -73,18 +73,18 @@ extension HashtagTimelineViewModel.LoadLatestState { // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load - if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last, + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0) + let newIDs = oldStatusIDs.removingDuplicates() - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = newIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index e971659e1..9bf87554b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -54,11 +54,11 @@ extension HashtagTimelineViewModel.LoadMiddleState { return } - guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { stateMachine.enter(Fail.self) return } - let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in status.id } @@ -86,27 +86,27 @@ extension HashtagTimelineViewModel.LoadMiddleState { let newStatusIDList = response.value.map { $0.id } - if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) { // When response data: // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load if let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + oldStatusIDs.removeDuplicates() } else { // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index // Then there is no need to set a `loadMiddleState` cell viewModel.needLoadMiddleIndex = nil } - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index d464d3a50..23ec99152 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -29,7 +29,7 @@ extension HashtagTimelineViewModel.LoadOldestState { class Initial: HashtagTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } return stateClass == Loading.self } } @@ -48,7 +48,7 @@ extension HashtagTimelineViewModel.LoadOldestState { return } - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else { stateMachine.enter(Idle.self) return } @@ -79,10 +79,10 @@ extension HashtagTimelineViewModel.LoadOldestState { } else { stateMachine.enter(Idle.self) } - let newStatusIDList = statuses.map { $0.id } - viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value + let fetchedStatusIDList = statuses.map { $0.id } + newStatusIDs.append(contentsOf: fetchedStatusIDList) + viewModel.fetchedResultsController.statusIDs.value = newStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index e7f167f2a..a6b1b0594 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -19,12 +19,11 @@ final class HashtagTimelineViewModel: NSObject { var disposeBag = Set() - var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() var needLoadMiddleIndex: Int? = nil // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: StatusFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -69,39 +68,14 @@ final class HashtagTimelineViewModel: NSObject { init(context: AppContext, hashTag: String) { self.context = context self.hashTag = hashTag - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() + let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value + self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) super.init() - fetchedResultsController.delegate = self - - timelinePredicate + fetchedResultsController.objectIDs .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - self.diffableDataSource?.defaultRowAnimation = .fade - try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } - } catch { - assertionFailure(error.localizedDescription) - } + .sink { [weak self] objectIds in + self?.generateStatusItems(newObjectIDs: objectIds) } .store(in: &disposeBag) } From ecd595c6e80464e902d3f91bbcfcacdf3ed7a9ec Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 16:56:31 +0800 Subject: [PATCH 18/19] chore: correct hashtag typo --- .../Protocol/StatusProvider/StatusProviderFacade.swift | 2 +- .../HashtagTimelineViewController.swift | 8 ++++---- .../HashtagTimelineViewModel+LoadLatestState.swift | 2 +- .../HashtagTimelineViewModel+LoadMiddleState.swift | 2 +- .../HashtagTimelineViewModel+LoadOldestState.swift | 2 +- .../HashtagTimeline/HashtagTimelineViewModel.swift | 10 +++++----- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index b94391abe..d1c24c97f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -67,7 +67,7 @@ extension StatusProviderFacade { static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { switch entity.type { case .hashtag(let text, _): - let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashTag: text) + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text) provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show) case .mention(let text, _): coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 9d638e6c6..a70fe7929 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -53,8 +53,8 @@ extension HashtagTimelineViewController { override func viewDidLoad() { super.viewDidLoad() - title = "#\(viewModel.hashTag)" - titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil) + title = "#\(viewModel.hashtag)" + titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color @@ -142,7 +142,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle) + titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle) } guard let histories = viewModel.hashtagEntity.value?.history else { return @@ -167,7 +167,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashtag)") coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index e772e8ea0..b2d121d50 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -50,7 +50,7 @@ extension HashtagTimelineViewModel.LoadLatestState { // TODO: only set large count when using Wi-Fi viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .receive(on: DispatchQueue.main) .sink { completion in diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index 9bf87554b..dcd3f81ac 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -67,7 +67,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index 23ec99152..d0607550e 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadOldestState { viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index a6b1b0594..b43b67143 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -15,7 +15,7 @@ import MastodonSDK final class HashtagTimelineViewModel: NSObject { - let hashTag: String + let hashtag: String var disposeBag = Set() @@ -65,9 +65,9 @@ final class HashtagTimelineViewModel: NSObject { var cellFrameCache = NSCache() - init(context: AppContext, hashTag: String) { + init(context: AppContext, hashtag: String) { self.context = context - self.hashTag = hashTag + self.hashtag = hashtag let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) super.init() @@ -84,13 +84,13 @@ final class HashtagTimelineViewModel: NSObject { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags) + let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags) context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { _ in } receiveValue: { [weak self] response in let matchedTag = response.value.hashtags.first { tag -> Bool in - return tag.name == self?.hashTag + return tag.name == self?.hashtag } self?.hashtagEntity.send(matchedTag) } From 08d105f7b706aff3e5bb198a6c4abab4252256c9 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 17:10:58 +0800 Subject: [PATCH 19/19] chore: make hashtag inject with compose kind --- .../Section/ComposeStatusSection.swift | 1 + .../Compose/ComposeViewModel+Diffable.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 28 ++++++++++++------- .../HashtagTimelineViewController.swift | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index ebf95a093..56aa32798 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -22,6 +22,7 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { case post + case hashtag(hashtag: String) case mention(mastodonUserObjectID: NSManagedObjectID) case reply(repliedToStatusObjectID: NSManagedObjectID) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 496ba2845..4d5a39be1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -62,7 +62,7 @@ extension ComposeViewModel { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) - case .mention, .post: + case .hashtag, .mention, .post: snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 39f732071..f52c38a17 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,8 +56,9 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) - // In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users. - var preInsertedContent: String? = nil + // for hashtag: #' ' + // for mention: @' ' + private(set) var preInsertedContent: String? // custom emojis var customEmojiViewModelSubscription: AnyCancellable? @@ -74,27 +75,34 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind, - preInsertedContent: String? = nil + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind - self.preInsertedContent = preInsertedContent switch composeKind { - case .post, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) - case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init - - if case let .mention(mastodonUserObjectID) = composeKind { + if case let .hashtag(text) = composeKind { + let initialComposeContent = "#" + text + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent + } else if case let .mention(mastodonUserObjectID) = composeKind { context.managedObjectContext.performAndWait { let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser let initialComposeContent = "@" + mastodonUser.acct UITextChecker.learnWord(initialComposeContent) - self.composeStatusAttribute.composeContent.value = initialComposeContent + " " + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent } + } else { + self.preInsertedContent = nil } isCustomEmojiComposing diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index a70fe7929..cefd7b238 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -167,7 +167,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashtag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .hashtag(hashtag: viewModel.hashtag)) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) }