From 675df849e3a2c4469fc63db7204b920148770ad7 Mon Sep 17 00:00:00 2001 From: woxtu Date: Wed, 16 Nov 2022 12:21:08 +0900 Subject: [PATCH 01/36] Remove redundant nil coalescing --- Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift | 2 +- .../Sources/MastodonUI/View/Content/StatusView+ViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift index 4926dbfce..05d5ecea2 100644 --- a/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/Cell/SettingsToggleTableViewCell.swift @@ -86,7 +86,7 @@ extension SettingsToggleTableViewCell { return nil default: // set tint black for Light Mode - return self.contentView.window?.tintColor ?? nil + return self.contentView.window?.tintColor } }() } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 416226cbb..bb866331b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -468,7 +468,7 @@ extension StatusView.ViewModel { pollCountdownDescription ) .sink { pollVoteDescription, pollCountdownDescription in - statusView.pollVoteCountLabel.text = pollVoteDescription ?? "-" + statusView.pollVoteCountLabel.text = pollVoteDescription statusView.pollCountdownLabel.text = pollCountdownDescription ?? "-" } .store(in: &disposeBag) From 366287a9f84eca283fe7a9295774548ae5456b76 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 23 Nov 2022 14:16:28 +0800 Subject: [PATCH 02/36] fix: use singleton AppContext to workaround reentry problem --- MastodonSDK/Sources/MastodonCore/AppContext.swift | 4 ++++ ShareActionExtension/Scene/ShareViewController.swift | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/MastodonSDK/Sources/MastodonCore/AppContext.swift b/MastodonSDK/Sources/MastodonCore/AppContext.swift index d44c1ea5a..d8aa06fae 100644 --- a/MastodonSDK/Sources/MastodonCore/AppContext.swift +++ b/MastodonSDK/Sources/MastodonCore/AppContext.swift @@ -115,6 +115,10 @@ public class AppContext: ObservableObject { .store(in: &disposeBag) } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension AppContext { diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift index 4a093becd..00c5b77de 100644 --- a/ShareActionExtension/Scene/ShareViewController.swift +++ b/ShareActionExtension/Scene/ShareViewController.swift @@ -21,7 +21,7 @@ final class ShareViewController: UIViewController { var disposeBag = Set() - let context = AppContext() + let context = AppContext.shared private(set) lazy var viewModel = ShareViewModel(context: context) let publishButton: UIButton = { @@ -63,6 +63,10 @@ final class ShareViewController: UIViewController { label.text = "No Available Account" // TODO: i18n return label }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } } @@ -155,7 +159,7 @@ extension ShareViewController { _ = try await statusPublisher.publish(api: context.apiService, authContext: authContext) self.publishButton.setTitle(L10n.Common.Controls.Actions.done, for: .normal) - try await Task.sleep(nanoseconds: 1 * .second) + try await Task.sleep(nanoseconds: 1 * .second) self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) @@ -325,3 +329,7 @@ extension ShareViewController { case missingAuthentication } } + +extension AppContext { + static let shared = AppContext() +} From b028380e7b9dfc848e8c79db834808979f7ece78 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 26 Nov 2022 05:21:27 +0800 Subject: [PATCH 03/36] fix: scroll to top animation cannot trigger smooth issue --- Mastodon/Scene/Profile/ProfileViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 1184fb3d7..ffe8bcc83 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -885,7 +885,7 @@ extension ProfileViewController: MastodonMenuDelegate { // MARK: - ScrollViewContainer extension ProfileViewController: ScrollViewContainer { var scrollView: UIScrollView { - return tabBarPagerController.containerScrollView + return tabBarPagerController.relayScrollView } } From ac5e68b74bac97886ca5a3dcc40f8db0b180e0dd Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 24 Nov 2022 07:46:27 +0100 Subject: [PATCH 04/36] feat: Delete Users / Statuses on Mute --- Mastodon/Supporting Files/SceneDelegate.swift | 3 + .../CoreData.xcdatamodeld/.xccurrentversion | 2 +- .../CoreData 5.xcdatamodel/contents | 254 ++++++++++++++++++ .../Service/API/APIService+Mute.swift | 30 +++ .../Service/InstanceService.swift | 17 ++ .../API/Mastodon+API+Account+Friendship.swift | 50 ++++ 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 4476477fd..15c4069cb 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -112,6 +112,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // trigger authenticated user account update AppContext.shared.authenticationService.updateActiveUserAccountPublisher.send() + + // update mutes and blocks and remove related data + AppContext.shared.instanceService.updateMutesAndBlocks() if let shortcutItem = savedShortCutItem { Task { diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index 1d5ea989f..2145ac780 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreData 4.xcdatamodel + CoreData 5.xcdatamodel diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents new file mode 100644 index 000000000..1388fa741 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index ee43ddce8..28be45309 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -21,6 +21,36 @@ extension APIService { let isMuting: Bool } + @discardableResult + public func getMutes( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let managedObjectContext = backgroundManagedObjectContext + + let response = try await Mastodon.API.Account.mutes( + session: session, + domain: authenticationBox.domain, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + let userIDs = response.value.map { $0.id } + let predicate = NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), userIDs) + + let fetchRequest = MastodonUser.fetchRequest() + fetchRequest.predicate = predicate + fetchRequest.includesPropertyValues = false + + try await managedObjectContext.performChanges { + let accounts = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] + + for account in accounts { + managedObjectContext.delete(account) + } + } + + return response + } + public func toggleMute( user: ManagedObjectRecord, authenticationBox: MastodonAuthenticationBox diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 4cd804036..742476c28 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -101,3 +101,20 @@ extension InstanceService { .store(in: &disposeBag) } } + +public extension InstanceService { + func updateMutesAndBlocks() { + Task { + for authBox in authenticationService?.mastodonAuthenticationBoxes ?? [] { + do { + try await apiService?.getMutes( + authenticationBox: authBox + ) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update mutes and blocks succeeded") + } catch { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update mutes and blocks failure: \(error.localizedDescription)") + } + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index 2c0c39b97..c461466e7 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -414,3 +414,53 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + static func mutesEndpointURL( + domain: String + ) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("mutes") + } + + /// View all mutes + /// + /// View your mutes. See also accounts/:id/{mute,unmute}. + /// + /// - 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 mutes( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: mutesEndpointURL(domain: domain), + query: MutesQuery(), + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + + struct MutesQuery: GetQuery { + var queryItems: [URLQueryItem]? { + [URLQueryItem(name: "limit", value: "-1")] + } + } + } +} From 65ed6650e817b3c388da606a9ed6eb7db21b4cb7 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 24 Nov 2022 07:53:04 +0100 Subject: [PATCH 05/36] feat: Implement deletion of records for blocked users --- ...omeTimelineViewModel+LoadLatestState.swift | 2 + .../Service/API/APIService+Block.swift | 30 ++++++++++++ .../Service/InstanceService.swift | 5 ++ .../API/Mastodon+API+Account+Friendship.swift | 47 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 8bf1421b1..d243c9a96 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -86,6 +86,8 @@ extension HomeTimelineViewModel.LoadLatestState { await enter(state: Idle.self) viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) + viewModel.context.instanceService.updateMutesAndBlocks() + // stop refresher if no new statuses let statuses = response.value let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index 7c78a65f7..d138d0401 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -22,6 +22,36 @@ extension APIService { let isFollowing: Bool } + @discardableResult + public func getBlocked( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let managedObjectContext = backgroundManagedObjectContext + + let response = try await Mastodon.API.Account.blocks( + session: session, + domain: authenticationBox.domain, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + let userIDs = response.value.map { $0.id } + let predicate = NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), userIDs) + + let fetchRequest = MastodonUser.fetchRequest() + fetchRequest.predicate = predicate + fetchRequest.includesPropertyValues = false + + try await managedObjectContext.performChanges { + let accounts = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] + + for account in accounts { + managedObjectContext.delete(account) + } + } + + return response + } + public func toggleBlock( user: ManagedObjectRecord, authenticationBox: MastodonAuthenticationBox diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 742476c28..99ad6d0a2 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -110,6 +110,11 @@ public extension InstanceService { try await apiService?.getMutes( authenticationBox: authBox ) + + try await apiService?.getBlocked( + authenticationBox: authBox + ) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update mutes and blocks succeeded") } catch { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Instance] update mutes and blocks failure: \(error.localizedDescription)") diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index c461466e7..65045fbd0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -215,6 +215,53 @@ extension Mastodon.API.Account { } +public extension Mastodon.API.Account { + + static func blocksEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("blocks") + } + + /// 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/blocks/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + static func blocks( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: blocksEndpointURL(domain: domain), + query: BlocksQuery(), + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + private struct BlocksQuery: GetQuery { + var queryItems: [URLQueryItem]? { + [URLQueryItem(name: "limit", value: "-1")] + } + } + +} + extension Mastodon.API.Account { static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { From 6be1e502b731a6473b15c49430d1b440db1a9673 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 24 Nov 2022 13:24:56 +0100 Subject: [PATCH 06/36] chore: Use MastodonUser.predicate in APIService+Mute --- .../Sources/MastodonCore/Service/API/APIService+Mute.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index 28be45309..0e00c5e0b 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -34,7 +34,7 @@ extension APIService { ).singleOutput() let userIDs = response.value.map { $0.id } - let predicate = NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), userIDs) + let predicate = MastodonUser.predicate(domain: authenticationBox.domain, ids: userIDs) let fetchRequest = MastodonUser.fetchRequest() fetchRequest.predicate = predicate From 9c86dfe166d38bec18d4b260feb555a4bb1f8f81 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 24 Nov 2022 13:26:35 +0100 Subject: [PATCH 07/36] chore: Remove limit query for GET Block/Mute --- .../MastodonSDK/API/Mastodon+API+Account+Friendship.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index 65045fbd0..2619891a7 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -256,7 +256,7 @@ public extension Mastodon.API.Account { private struct BlocksQuery: GetQuery { var queryItems: [URLQueryItem]? { - [URLQueryItem(name: "limit", value: "-1")] + nil } } @@ -506,7 +506,7 @@ extension Mastodon.API.Account { struct MutesQuery: GetQuery { var queryItems: [URLQueryItem]? { - [URLQueryItem(name: "limit", value: "-1")] + nil } } } From 12cb8cf8d6a9b2ec8632e90a52a11cb01f29fb3d Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 24 Nov 2022 14:21:53 +0100 Subject: [PATCH 08/36] feat: Implement blocks/mutes pagination using link header --- .../Service/API/APIService+Block.swift | 22 ++++++++-- .../Service/API/APIService+Mute.swift | 20 +++++++-- .../API/Mastodon+API+Account+Friendship.swift | 42 +++++++++++++++++-- .../Response/Mastodon+Response+Content.swift | 28 +++++++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index d138d0401..c53428fbb 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -25,18 +25,27 @@ extension APIService { @discardableResult public func getBlocked( authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + try await _getBlocked(sinceID: nil, limit: 40, authenticationBox: authenticationBox) + } + + private func _getBlocked( + sinceID: Mastodon.Entity.Status.ID?, + limit: Int, + authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { let managedObjectContext = backgroundManagedObjectContext - let response = try await Mastodon.API.Account.blocks( session: session, domain: authenticationBox.domain, + sinceID: sinceID, + limit: limit, authorization: authenticationBox.userAuthorization ).singleOutput() let userIDs = response.value.map { $0.id } - let predicate = NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), userIDs) - + let predicate = MastodonUser.predicate(domain: authenticationBox.domain, ids: userIDs) + let fetchRequest = MastodonUser.fetchRequest() fetchRequest.predicate = predicate fetchRequest.includesPropertyValues = false @@ -49,7 +58,12 @@ extension APIService { } } - return response + /// only try to paginate if retrieved userIDs count is larger than the set limit and if we get a prev linkId that's different than the currently used one + guard userIDs.count == limit, let prevSinceId = response.link?.linkIDs[.linkPrev]?.sinceId, sinceID != prevSinceId else { + return response + } + + return try await _getBlocked(sinceID: prevSinceId, limit: limit, authenticationBox: authenticationBox) } public func toggleBlock( diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index 0e00c5e0b..42178d573 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -24,18 +24,27 @@ extension APIService { @discardableResult public func getMutes( authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + try await _getMutes(sinceID: nil, limit: 40, authenticationBox: authenticationBox) + } + + private func _getMutes( + sinceID: Mastodon.Entity.Status.ID?, + limit: Int, + authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { let managedObjectContext = backgroundManagedObjectContext - let response = try await Mastodon.API.Account.mutes( session: session, domain: authenticationBox.domain, + sinceID: sinceID, + limit: limit, authorization: authenticationBox.userAuthorization ).singleOutput() let userIDs = response.value.map { $0.id } let predicate = MastodonUser.predicate(domain: authenticationBox.domain, ids: userIDs) - + let fetchRequest = MastodonUser.fetchRequest() fetchRequest.predicate = predicate fetchRequest.includesPropertyValues = false @@ -48,7 +57,12 @@ extension APIService { } } - return response + /// only try to paginate if retrieved userIDs count is larger than the set limit and if we get a prev linkId that's different than the currently used one + guard userIDs.count == limit, let prevSinceId = response.link?.linkIDs[.linkPrev]?.sinceId, sinceID != prevSinceId else { + return response + } + + return try await _getMutes(sinceID: prevSinceId, limit: limit, authenticationBox: authenticationBox) } public func toggleMute( diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index 2619891a7..e191c3200 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -239,11 +239,13 @@ public extension Mastodon.API.Account { static func blocks( session: URLSession, domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: blocksEndpointURL(domain: domain), - query: BlocksQuery(), + query: BlocksQuery(sinceID: sinceID, limit: limit), authorization: authorization ) return session.dataTaskPublisher(for: request) @@ -255,8 +257,23 @@ public extension Mastodon.API.Account { } private struct BlocksQuery: GetQuery { + private let sinceID: Mastodon.Entity.Status.ID? + private let limit: Int? + + public init( + sinceID: Mastodon.Entity.Status.ID?, + limit: Int? + ) { + self.sinceID = sinceID + self.limit = limit + } + var queryItems: [URLQueryItem]? { - nil + var items: [URLQueryItem] = [] + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items } } @@ -490,11 +507,13 @@ extension Mastodon.API.Account { public static func mutes( session: URLSession, domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int?, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: mutesEndpointURL(domain: domain), - query: MutesQuery(), + query: MutesQuery(sinceID: sinceID, limit: limit), authorization: authorization ) return session.dataTaskPublisher(for: request) @@ -505,8 +524,23 @@ extension Mastodon.API.Account { .eraseToAnyPublisher() struct MutesQuery: GetQuery { + private let sinceID: Mastodon.Entity.Status.ID? + private let limit: Int? + + public init( + sinceID: Mastodon.Entity.Status.ID?, + limit: Int? + ) { + self.sinceID = sinceID + self.limit = limit + } + var queryItems: [URLQueryItem]? { - nil + var items: [URLQueryItem] = [] + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index 6cf95752b..aa156ac16 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -106,6 +106,7 @@ extension Mastodon.Response { public struct Link { public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? + public let linkIDs: [String: Mastodon.Entity.Status.ID] public let offset: Int? init(link: String) { @@ -135,6 +136,33 @@ extension Mastodon.Response { let offset = link[range] return Int(offset) }() + self.linkIDs = { + var linkIDs = [String: Mastodon.Entity.Status.ID]() + let links = link.components(separatedBy: ", ") + for link in links { + guard let regex = try? NSRegularExpression(pattern: "<(.*)>; *rel=\"(.*)\"") else { return [:] } + let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex.. _XCCurrentVersionName - CoreData 5.xcdatamodel + CoreData 4.xcdatamodel diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents deleted file mode 100644 index 1388fa741..000000000 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index 62d4b435c..60f15367e 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -51,10 +51,10 @@ extension APIService { fetchRequest.includesPropertyValues = false try await managedObjectContext.performChanges { - let accounts = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] + let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] - for account in accounts { - managedObjectContext.delete(account) + for user in users { + user.statuses.deleteAllFeedsForBlockOrMute(in: managedObjectContext) } } @@ -149,3 +149,11 @@ extension APIService { } } + +extension Set { + func deleteAllFeedsForBlockOrMute(in managedObjectContext: NSManagedObjectContext) { + map { $0.feeds.union($0.reblogFrom.map { $0.feeds }.flatMap { $0 }) } + .flatMap { $0 } + .forEach(managedObjectContext.delete) + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index 1d6738e32..e0e95fd35 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -50,10 +50,10 @@ extension APIService { fetchRequest.includesPropertyValues = false try await managedObjectContext.performChanges { - let accounts = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] + let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] - for account in accounts { - managedObjectContext.delete(account) + for user in users { + user.statuses.deleteAllFeedsForBlockOrMute(in: managedObjectContext) } } From 54a75d4138490a75dcef02d90c79af85a52e7130 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 14:43:36 +0100 Subject: [PATCH 12/36] feat: Delete Status and Notification Feeds for Blocked/Muted Users --- .../Service/API/APIService+Block.swift | 20 ++++++++++++++----- .../Service/API/APIService+Mute.swift | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index 60f15367e..19c5ff437 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -54,7 +54,7 @@ extension APIService { let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] for user in users { - user.statuses.deleteAllFeedsForBlockOrMute(in: managedObjectContext) + user.deleteStatusAndNotificationFeeds(in: managedObjectContext) } } @@ -150,10 +150,20 @@ extension APIService { } -extension Set { - func deleteAllFeedsForBlockOrMute(in managedObjectContext: NSManagedObjectContext) { - map { $0.feeds.union($0.reblogFrom.map { $0.feeds }.flatMap { $0 }) } +extension MastodonUser { + func deleteStatusAndNotificationFeeds(in context: NSManagedObjectContext) { + statuses.map { + $0.feeds + .union($0.reblogFrom.map { $0.feeds }.flatMap { $0 }) + .union($0.notifications.map { $0.feeds }.flatMap { $0 }) + } .flatMap { $0 } - .forEach(managedObjectContext.delete) + .forEach(context.delete) + + notifications.map { + $0.feeds + } + .flatMap { $0 } + .forEach(context.delete) } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index e0e95fd35..cc46872f4 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -53,7 +53,7 @@ extension APIService { let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] for user in users { - user.statuses.deleteAllFeedsForBlockOrMute(in: managedObjectContext) + user.deleteStatusAndNotificationFeeds(in: managedObjectContext) } } From 0e5261ef9476d18794aceb3401cf45e775bfb597 Mon Sep 17 00:00:00 2001 From: Kyle Bashour Date: Sat, 26 Nov 2022 16:08:52 -0800 Subject: [PATCH 13/36] Fix scroll to top --- .../Root/MainTab/MainTabBarController.swift | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 2e5d5ae58..a2778b551 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -569,25 +569,24 @@ extension MainTabBarController: UITabBarControllerDelegate { composeButtonDidPressed(tabBarController) return false } + + // Assert index is as same as the tab rawValue. This check needs to be done `shouldSelect` + // because the nav controller has already popped in `didSelect`. + if currentTab.rawValue == tabBarController.selectedIndex, + let navigationController = viewController as? UINavigationController, + navigationController.viewControllers.count == 1, + let scrollViewContainer = navigationController.topViewController as? ScrollViewContainer { + scrollViewContainer.scrollToTop(animated: true) + } + return true } func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, viewController.debugDescription) - defer { - if let tab = Tab(rawValue: viewController.tabBarItem.tag) { - currentTab = tab - } + if let tab = Tab(rawValue: viewController.tabBarItem.tag) { + currentTab = tab } - // assert index is as same as the tab rawValue - guard currentTab.rawValue == tabBarController.selectedIndex, - let navigationController = viewController as? UINavigationController, - navigationController.viewControllers.count == 1, - let scrollViewContainer = navigationController.topViewController as? ScrollViewContainer else { - return - } - - scrollViewContainer.scrollToTop(animated: true) } } From 03918301fbc580e5f2f70a604b23dd9bf251b335 Mon Sep 17 00:00:00 2001 From: Kyle Bashour Date: Sat, 26 Nov 2022 19:26:10 -0800 Subject: [PATCH 14/36] Space buttons instead of stretching --- .../MastodonUI/View/Control/ActionToolbarContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift index 446b4af2a..73695b782 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ActionToolbarContainer.swift @@ -105,7 +105,7 @@ extension ActionToolbarContainer { shareButton.setImage(ActionToolbarContainer.shareImage, for: .normal) container.axis = .horizontal - container.distribution = .fill + container.distribution = .equalSpacing replyButton.translatesAutoresizingMaskIntoConstraints = false reblogButton.translatesAutoresizingMaskIntoConstraints = false From 248d0a5570c99de2a195fd3b767d0af6722d99fc Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 29 Nov 2022 11:51:28 +0100 Subject: [PATCH 15/36] fix: Text in compose post is not selectable, focussable, pastable --- .../Scene/ComposeContent/Attachment/AttachmentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift index 9346c3bee..edf05b49c 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -30,6 +30,7 @@ public struct AttachmentView: View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) + .allowsHitTesting(false) } ) .overlay( From 2003e4987cfa4869a502f9074a03b53f8ff1c81a Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Wed, 30 Nov 2022 09:09:24 -0500 Subject: [PATCH 16/36] =?UTF-8?q?Rename=20second=20tab=20to=20=E2=80=9CSea?= =?UTF-8?q?rch=20and=20Explore=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Localization/StringsConvertor/input/Base.lproj/app.json | 2 +- Localization/app.json | 2 +- Mastodon/Scene/Root/MainTab/MainTabBarController.swift | 2 +- .../Sources/MastodonLocalization/Generated/Strings.swift | 4 ++-- .../Resources/Base.lproj/Localizable.strings | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index ea046bfbc..df1a45fe1 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -95,7 +95,7 @@ }, "tabs": { "home": "Home", - "search": "Search", + "search_and_explore": "Search and Explore", "notifications": "Notifications", "profile": "Profile" }, diff --git a/Localization/app.json b/Localization/app.json index ea046bfbc..df1a45fe1 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -95,7 +95,7 @@ }, "tabs": { "home": "Home", - "search": "Search", + "search_and_explore": "Search and Explore", "notifications": "Notifications", "profile": "Profile" }, diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index a2778b551..07a187188 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -61,7 +61,7 @@ class MainTabBarController: UITabBarController { var title: String { switch self { case .home: return L10n.Common.Controls.Tabs.home - case .search: return L10n.Common.Controls.Tabs.search + case .search: return L10n.Common.Controls.Tabs.searchAndExplore case .compose: return L10n.Common.Controls.Actions.compose case .notifications: return L10n.Common.Controls.Tabs.notifications case .me: return L10n.Common.Controls.Tabs.profile diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 0392d2b05..3dd0a025e 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -370,8 +370,8 @@ public enum L10n { public static let notifications = L10n.tr("Localizable", "Common.Controls.Tabs.Notifications", fallback: "Notifications") /// Profile public static let profile = L10n.tr("Localizable", "Common.Controls.Tabs.Profile", fallback: "Profile") - /// Search - public static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search", fallback: "Search") + /// Search and Explore + public static let searchAndExplore = L10n.tr("Localizable", "Common.Controls.Tabs.SearchAndExplore", fallback: "Search and Explore") } public enum Timeline { /// Filtered diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index adeaad07b..089f0fa8e 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -133,7 +133,7 @@ Please check your internet connection."; "Common.Controls.Tabs.Home" = "Home"; "Common.Controls.Tabs.Notifications" = "Notifications"; "Common.Controls.Tabs.Profile" = "Profile"; -"Common.Controls.Tabs.Search" = "Search"; +"Common.Controls.Tabs.SearchAndExplore" = "Search and Explore"; "Common.Controls.Timeline.Filtered" = "Filtered"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile until they unblock you."; From fd9a253fac5311c9096c1352f4d8c41eb2b8303b Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Dec 2022 15:29:56 +0800 Subject: [PATCH 17/36] fix: workaround paste crash on iOS 14.x issue --- .../Vendor/MetaTextView+PasteExtensions.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift b/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift index 8fe1949af..12e6632a7 100644 --- a/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift +++ b/MastodonSDK/Sources/MastodonUI/Vendor/MetaTextView+PasteExtensions.swift @@ -13,17 +13,21 @@ extension MetaTextView { public override func paste(_ sender: Any?) { super.paste(sender) - var nextResponder = self.next; - - // Force the event to bubble through ALL responders - // This is a workaround as somewhere down the chain the paste event gets eaten - while (nextResponder != nil) { - if let nextResponder = nextResponder { - if (nextResponder.responds(to: #selector(UIResponderStandardEditActions.paste(_:)))) { - nextResponder.perform(#selector(UIResponderStandardEditActions.paste(_:)), with: sender) + // fix #660 + // https://github.com/mastodon/mastodon-ios/issues/660 + if #available(iOS 15.0, *) { + var nextResponder = self.next; + + // Force the event to bubble through ALL responders + // This is a workaround as somewhere down the chain the paste event gets eaten + while (nextResponder != nil) { + if let nextResponder = nextResponder { + if (nextResponder.responds(to: #selector(UIResponderStandardEditActions.paste(_:)))) { + nextResponder.perform(#selector(UIResponderStandardEditActions.paste(_:)), with: sender) + } } + nextResponder = nextResponder?.next; } - nextResponder = nextResponder?.next; - } + } // end if } } From ee9f0538e359c095ae2e5a4c0564f5995dcb9988 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Dec 2022 15:48:01 +0800 Subject: [PATCH 18/36] fix: timeline reload blink issue --- .../HomeTimelineViewModel+Diffable.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index cabc655c9..29bff623b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -41,7 +41,7 @@ extension HomeTimelineViewModel { guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") - Task { + Task { @MainActor in let start = CACurrentMediaTime() defer { let end = CACurrentMediaTime() @@ -98,22 +98,22 @@ extension HomeTimelineViewModel { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") } - guard let difference = await self.calculateReloadSnapshotDifference( + guard let difference = self.calculateReloadSnapshotDifference( tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot ) else { - await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + self.updateSnapshotUsingReloadData(snapshot: newSnapshot) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") return } - await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) - await tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) - var contentOffset = await tableView.contentOffset - contentOffset.y = await tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge - await tableView.setContentOffset(contentOffset, animated: false) + self.updateSnapshotUsingReloadData(snapshot: newSnapshot) + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + var contentOffset = tableView.contentOffset + contentOffset.y = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge + tableView.setContentOffset(contentOffset, animated: false) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") } // end Task @@ -135,9 +135,9 @@ extension HomeTimelineViewModel { @MainActor func updateSnapshotUsingReloadData( snapshot: NSDiffableDataSourceSnapshot - ) async { + ) { if #available(iOS 15.0, *) { - await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) + self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) } else { diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) } From b7458dfc7b596d8fef819fe749c814095a95c722 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 22 Nov 2022 11:11:36 +0100 Subject: [PATCH 19/36] feat: Implement hashtag button on Profile --- .../Scene/Profile/ProfileViewController.swift | 26 ++ .../CoreData.xcdatamodeld/.xccurrentversion | 2 +- .../CoreData 5.xcdatamodel/contents | 255 ++++++++++++++++++ .../Entity/Mastodon/Instance.swift | 10 +- .../Extension/CoreDataStack/Instance.swift | 7 + .../APIService+CoreData+Instance.swift | 5 +- .../Mastodon+API+Account+FollowedTags.swift | 73 +++++ .../Entity/Mastodon+Entity+Tag.swift | 6 +- 8 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index ffe8bcc83..62aa8e57a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -104,6 +104,13 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.seeMore return barButtonItem }() + + private(set) lazy var followedTagsBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "number"), style: .plain, target: self, action: #selector(ProfileViewController.followedTagsItemPressed(_:))) + barButtonItem.tintColor = .white + //barButtonItem.accessibilityLabel = "" TODO: add missing L10n.Common.Controls.Actions.XXXXXX + return barButtonItem + }() let refreshControl: RefreshControl = { let refreshControl = RefreshControl() @@ -243,6 +250,11 @@ extension ProfileViewController { items.append(self.shareBarButtonItem) items.append(self.favoriteBarButtonItem) items.append(self.bookmarkBarButtonItem) + + if self.currentInstance?.canFollowTags == true { + items.append(self.followedTagsBarButtonItem) + } + return } @@ -545,6 +557,10 @@ extension ProfileViewController { ) _ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } + + @objc private func followedTagsItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } @objc private func refreshControlValueChanged(_ sender: RefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -914,3 +930,13 @@ extension ProfileViewController: PagerTabStripNavigateable { } +private extension ProfileViewController { + var currentInstance: Instance? { + guard let authenticationRecord = authContext.mastodonAuthenticationBox + .authenticationRecord + .object(in: context.managedObjectContext) + else { return nil } + + return authenticationRecord.instance + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index 1d5ea989f..2145ac780 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreData 4.xcdatamodel + CoreData 5.xcdatamodel diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents new file mode 100644 index 000000000..69ffac191 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift index 8976097ef..cc21e8351 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Instance.swift @@ -10,7 +10,8 @@ import CoreData public final class Instance: NSManagedObject { @NSManaged public var domain: String - + @NSManaged public var version: String? + @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -35,6 +36,7 @@ extension Instance { ) -> Instance { let instance: Instance = context.insertObject() instance.domain = property.domain + instance.version = property.version return instance } @@ -50,9 +52,11 @@ extension Instance { extension Instance { public struct Property { public let domain: String - - public init(domain: String) { + public let version: String? + + public init(domain: String, version: String?) { self.domain = domain + self.version = version } } } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift index 4192b68a2..7e925b665 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Instance.swift @@ -23,3 +23,10 @@ extension Instance { return try? JSONEncoder().encode(configuration) } } + +extension Instance { + public var canFollowTags: Bool { + guard let majorVersionString = version?.split(separator: ".").first else { return false } + return Int(majorVersionString) == 4 // following Tags is support beginning with Mastodon v4.0.0 + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+Instance.swift b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+Instance.swift index 2127a2981..d9566f6d4 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+Instance.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/CoreData/APIService+CoreData+Instance.swift @@ -46,7 +46,7 @@ extension APIService.CoreData { } else { let instance = Instance.insert( into: managedObjectContext, - property: Instance.Property(domain: domain) + property: Instance.Property(domain: domain, version: entity.version) ) let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } instance.update(configurationRaw: configurationRaw) @@ -69,7 +69,8 @@ extension APIService.CoreData { let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) } instance.update(configurationRaw: configurationRaw) - + instance.version = entity.version + instance.didUpdate(at: networkDate) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift new file mode 100644 index 000000000..f1020180f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift @@ -0,0 +1,73 @@ +// +// Mastodon+API+Account+FollowedTags.swift +// +// +// Created by Marcus Kida on 22.11.22. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func followedTagsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("followed_tags") + } + + /// Followed Tags + /// + /// View your followed hashtags. + /// + /// - Since: 4.0.0 + /// - Version: 4.0.3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/followed_tags/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Tag]` nested in the response + public static func followers( + session: URLSession, + domain: String, + query: FollowedTagsQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: followedTagsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Tag].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowedTagsQuery: Codable, GetQuery { + + public let limit: Int? // default 100 + + enum CodingKeys: String, CodingKey { + case limit + } + + public init( + limit: Int? + ) { + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 84875359a..9c091d73f 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -11,9 +11,9 @@ extension Mastodon.Entity { /// Tag /// /// - Since: 0.9.0 - /// - Version: 3.3.0 + /// - Version: 4.0.0 /// # Last Update - /// 2021/1/28 + /// 2022/11/22 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/tag/) public struct Tag: Hashable, Codable { @@ -23,11 +23,13 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + public let following: Bool? enum CodingKeys: String, CodingKey { case name case url case history + case following } public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { From 2987bb29fabf9abe8b5059186a9f6e4a9db3f6d2 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 22 Nov 2022 14:15:41 +0100 Subject: [PATCH 20/36] feat: Add FollowedTagsViewController --- Mastodon.xcodeproj/project.pbxproj | 12 ++++++ .../FollowedTagsViewController.swift | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0c43b71df..46d8bc2ea 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; + 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; @@ -520,6 +521,7 @@ 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; + 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; @@ -1226,6 +1228,14 @@ path = Pods; sourceTree = ""; }; + 2A506CF2292CD83B00059C37 /* FollowedTags */ = { + isa = PBXGroup; + children = ( + 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */, + ); + path = FollowedTags; + sourceTree = ""; + }; 2D152A8A25C295B8009AA50C /* Content */ = { isa = PBXGroup; children = ( @@ -2369,6 +2379,7 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( + 2A506CF2292CD83B00059C37 /* FollowedTags */, 62047EBE28874C8F00A3BA5D /* Bookmark */, DBB525462611ED57002F1F29 /* Header */, DBB525262611EBDA002F1F29 /* Paging */, @@ -3301,6 +3312,7 @@ DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, + 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */, DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift new file mode 100644 index 000000000..2f705dcec --- /dev/null +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift @@ -0,0 +1,38 @@ +// +// FollowedTagsViewController.swift +// Mastodon +// +// Created by Marcus Kida on 22.11.22. +// + +import os +import UIKit +import Combine +import MastodonAsset +import MastodonCore +import MastodonUI +import MastodonLocalization + +final class FollowedTagsViewController: UIViewController, NeedsDependency { + let logger = Logger(subsystem: String(describing: FollowedTagsViewController.self), category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: BookmarkViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } +} From 178a6e503a7d7b5f668341083aea4b31dfa879b7 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 23 Nov 2022 15:57:51 +0100 Subject: [PATCH 21/36] feat: Implement layout for hashtag timeline header view --- Mastodon.xcodeproj/project.pbxproj | 4 + .../HashtagTimelineHeaderView.swift | 109 ++++++++++++++++++ .../HashtagTimelineViewController.swift | 29 +++++ .../HashtagTimelineViewModel.swift | 18 ++- .../Service/API/APIService+Account.swift | 37 ++++++ .../Service/API/APIService+Tags.swift | 49 ++++++++ .../Mastodon+API+Account+FollowedTags.swift | 2 +- .../MastodonSDK/API/Mastodon+API+Tags.swift | 49 ++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 9 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 46d8bc2ea..2102647ec 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; + 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; }; 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; @@ -522,6 +523,7 @@ 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; + 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; @@ -1126,6 +1128,7 @@ 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */, + 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */, ); path = HashtagTimeline; sourceTree = ""; @@ -3301,6 +3304,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, DB025B78278D606A002F581E /* StatusItem.swift in Sources */, DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */, + 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift new file mode 100644 index 000000000..80a6795f3 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -0,0 +1,109 @@ +// +// HashtagTimelineHeaderView.swift +// Mastodon +// +// Created by Marcus Kida on 22.11.22. +// + +import UIKit +import MastodonSDK +import MastodonUI + +fileprivate extension CGFloat { + static let padding: CGFloat = 16 + static let descriptionLabelSpacing: CGFloat = 12 +} + +final class HashtagTimelineHeaderView: UIView { + let titleLabel = UILabel() + + let postCountLabel = UILabel() + let participantsLabel = UILabel() + let postsTodayLabel = UILabel() + + let postCountDescLabel = UILabel() + let participantsDescLabel = UILabel() + let postsTodayDescLabel = UILabel() + + let followButton: UIButton = { + let button = RoundedEdgesButton(type: .custom) + button.cornerRadius = 10 + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.backgroundColor = .black + return button + }() + + init() { + super.init(frame: .zero) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension HashtagTimelineHeaderView { + func setupLayout() { + [titleLabel, postCountLabel, participantsLabel, postsTodayLabel, postCountDescLabel, participantsDescLabel, postsTodayDescLabel, followButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + // hashtag name / title + titleLabel.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) + + [postCountLabel, participantsLabel, postsTodayLabel].forEach { + $0.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: .systemFont(ofSize: 20, weight: .bold)) + $0.text = "999" + } + + [postCountDescLabel, participantsDescLabel, postsTodayDescLabel].forEach { + $0.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular)) + } + + postCountDescLabel.text = "posts" + participantsDescLabel.text = "participants" + postsTodayDescLabel.text = "posts today" + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: .padding), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .padding), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding), + + postCountLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding), + postCountLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + postCountDescLabel.leadingAnchor.constraint(equalTo: postCountLabel.leadingAnchor), + + participantsDescLabel.leadingAnchor.constraint(equalTo: postCountDescLabel.trailingAnchor, constant: .descriptionLabelSpacing), + participantsLabel.centerXAnchor.constraint(equalTo: participantsDescLabel.centerXAnchor), + participantsLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding), + + postsTodayDescLabel.leadingAnchor.constraint(equalTo: participantsDescLabel.trailingAnchor, constant: .descriptionLabelSpacing), + postsTodayLabel.centerXAnchor.constraint(equalTo: postsTodayDescLabel.centerXAnchor), + postsTodayLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding), + + postCountDescLabel.topAnchor.constraint(equalTo: postCountLabel.bottomAnchor), + participantsDescLabel.topAnchor.constraint(equalTo: participantsLabel.bottomAnchor), + postsTodayDescLabel.topAnchor.constraint(equalTo: postsTodayLabel.bottomAnchor), + + postCountDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding), + participantsDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding), + postsTodayDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding), + + followButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding), + followButton.bottomAnchor.constraint(equalTo: postsTodayDescLabel.bottomAnchor), + followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor), + followButton.widthAnchor.constraint(equalToConstant: 84) + ]) + } +} + +extension HashtagTimelineHeaderView { + func update(_ entity: Mastodon.Entity.Tag) { + titleLabel.text = "#\(entity.name)" + followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal) + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 4a0be3816..d48220615 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -15,6 +15,7 @@ import MastodonAsset import MastodonCore import MastodonUI import MastodonLocalization +import MastodonSDK final class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -27,6 +28,18 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me var disposeBag = Set() var viewModel: HashtagTimelineViewModel! + + private lazy var headerView: HashtagTimelineHeaderView = { + let headerView = HashtagTimelineHeaderView() + headerView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + headerView.heightAnchor.constraint(equalToConstant: 118), +// headerView.widthAnchor.constraint(equalTo: tableView.widthAnchor) + ]) + + return headerView + }() let composeBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() @@ -114,6 +127,14 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) + + viewModel.hashtagDetails + .receive(on: DispatchQueue.main) + .sink { [weak self] tag in + guard let tag = tag else { return } + self?.updateHeaderView(with: tag) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -148,7 +169,15 @@ extension HashtagTimelineViewController { subtitle = L10n.Plural.peopleTalking(peopleTalkingNumber) } } +} +extension HashtagTimelineViewController { + private func updateHeaderView(with tag: Mastodon.Entity.Tag) { + if tableView.tableHeaderView == nil { + tableView.tableHeaderView = headerView + } + headerView.update(tag) + } } extension HashtagTimelineViewController { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index af4d2a01a..86ae80704 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -23,7 +23,7 @@ final class HashtagTimelineViewModel { var disposeBag = Set() var needLoadMiddleIndex: Int? = nil - + // input let context: AppContext let authContext: AuthContext @@ -32,10 +32,11 @@ final class HashtagTimelineViewModel { let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) let listBatchFetchViewModel = ListBatchFetchViewModel() - + // output var diffableDataSource: UITableViewDiffableDataSource? let didLoadLatest = PassthroughSubject() + let hashtagDetails = CurrentValueSubject(nil) // bottom loader private(set) lazy var stateMachine: GKStateMachine = { @@ -61,6 +62,7 @@ final class HashtagTimelineViewModel { domain: authContext.mastodonAuthenticationBox.domain, additionalTweetPredicate: nil ) + updateTagInformation() // end init } @@ -70,3 +72,15 @@ final class HashtagTimelineViewModel { } +private extension HashtagTimelineViewModel { + func updateTagInformation() { + Task { @MainActor in + let tag = try? await context.apiService.getTagInformation( + for: hashtag, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + + self.hashtagDetails.send(tag) + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index 1b6a57a83..c27f8cf67 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -161,3 +161,40 @@ extension APIService { } } + +extension APIService { + public func getFollowedTags( + domain: String, + query: Mastodon.API.Account.FollowedTagsQuery, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Account.followedTags( + session: session, + domain: domain, + query: query, + authorization: authorization + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + for entity in response.value { + _ = Persistence.Tag.createOrMerge( + in: managedObjectContext, + context: Persistence.Tag.PersistContext( + domain: domain, + entity: entity, + me: me, + networkDate: response.networkDate + ) + ) + } + } + + return response + } // end func +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift new file mode 100644 index 000000000..80e0d083f --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift @@ -0,0 +1,49 @@ +// +// APIService+Tags.swift +// +// +// Created by Marcus Kida on 23.11.22. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + public func getTagInformation( + for tag: String, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Tags.tag( + session: session, + domain: domain, + tagId: tag, + authorization: authorization + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + + _ = Persistence.Tag.createOrMerge( + in: managedObjectContext, + context: Persistence.Tag.PersistContext( + domain: domain, + entity: response.value, + me: me, + networkDate: response.networkDate + ) + ) + } + + return response + } // end func + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift index f1020180f..02c79e35b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift @@ -28,7 +28,7 @@ extension Mastodon.API.Account { /// - domain: Mastodon instance domain. e.g. "example.com" /// - authorization: User token /// - Returns: `AnyPublisher` contains `[Tag]` nested in the response - public static func followers( + public static func followedTags( session: URLSession, domain: String, query: FollowedTagsQuery, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift new file mode 100644 index 000000000..d8c8f6354 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift @@ -0,0 +1,49 @@ +// +// Mastodin+API+Tags.swift +// +// +// Created by Marcus Kida on 23.11.22. +// + +import Combine +import Foundation + +extension Mastodon.API.Tags { + static func tagsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("tags") + } + + /// Followed Tags + /// + /// View your followed hashtags. + /// + /// - Since: 4.0.0 + /// - Version: 4.0.3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/tags/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - tagId: The Hashtag + /// - Returns: `AnyPublisher` contains `Tag` nested in the response + public static func tag( + session: URLSession, + domain: String, + tagId: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5d507bace..a1eb47873 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -112,6 +112,7 @@ extension Mastodon.API { public enum Polls { } public enum Reblog { } public enum Statuses { } + public enum Tags {} public enum Timeline { } public enum Trends { } public enum Suggestions { } From 9d245d3205293a2a22275055babc6532cc8c41d8 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 23 Nov 2022 17:24:25 +0100 Subject: [PATCH 22/36] feat: Fix follow hashtags header layout --- .../HashtagTimeline/HashtagTimelineHeaderView.swift | 12 +++++++++++- .../HashtagTimelineViewController.swift | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 80a6795f3..49442c03d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -25,6 +25,8 @@ final class HashtagTimelineHeaderView: UIView { let participantsDescLabel = UILabel() let postsTodayDescLabel = UILabel() + private var widthConstraint: NSLayoutConstraint! + let followButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.cornerRadius = 10 @@ -67,8 +69,12 @@ private extension HashtagTimelineHeaderView { postCountDescLabel.text = "posts" participantsDescLabel.text = "participants" postsTodayDescLabel.text = "posts today" - + + widthConstraint = widthAnchor.constraint(equalToConstant: 0) + NSLayoutConstraint.activate([ + widthConstraint, + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: .padding), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .padding), titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding), @@ -106,4 +112,8 @@ extension HashtagTimelineHeaderView { titleLabel.text = "#\(entity.name)" followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal) } + + func updateWidthConstraint(_ constant: CGFloat) { + widthConstraint.constant = constant + } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index d48220615..8d79b0ed1 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -178,6 +178,11 @@ extension HashtagTimelineViewController { } headerView.update(tag) } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + headerView.updateWidthConstraint(tableView.bounds.width) + } } extension HashtagTimelineViewController { From b020f566f47012e9e87edb48537e767d32129700 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 23 Nov 2022 17:54:53 +0100 Subject: [PATCH 23/36] feat: Implement real numbers of HashtagTimelineHeader --- .../HashtagTimelineHeaderView.swift | 27 +++++++++++-- .../Button/tagFollow.colorset/Contents.json | 38 +++++++++++++++++++ .../Button/tagUnfollow.colorset/Contents.json | 38 +++++++++++++++++++ .../MastodonAsset/Generated/Assets.swift | 2 + 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json create mode 100644 MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 49442c03d..996c18ffb 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -8,6 +8,7 @@ import UIKit import MastodonSDK import MastodonUI +import MastodonAsset fileprivate extension CGFloat { static let padding: CGFloat = 16 @@ -32,7 +33,6 @@ final class HashtagTimelineHeaderView: UIView { button.cornerRadius = 10 button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) - button.backgroundColor = .black return button }() @@ -101,8 +101,7 @@ private extension HashtagTimelineHeaderView { followButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding), followButton.bottomAnchor.constraint(equalTo: postsTodayDescLabel.bottomAnchor), - followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor), - followButton.widthAnchor.constraint(equalToConstant: 84) + followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor) ]) } } @@ -111,6 +110,28 @@ extension HashtagTimelineHeaderView { func update(_ entity: Mastodon.Entity.Tag) { titleLabel.text = "#\(entity.name)" followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal) + + followButton.backgroundColor = entity.following == true ? Asset.Colors.Button.tagUnfollow.color : Asset.Colors.Button.tagFollow.color + followButton.setTitleColor( + entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color, + for: .normal + ) + + if let history = entity.history { + postCountLabel.text = String( + history.reduce(0) { res, acc in + res + (Int(acc.uses) ?? 0) + } + ) + + participantsLabel.text = String( + history.reduce(0) { res, acc in + res + (Int(acc.accounts) ?? 0) + } + ) + + postsTodayLabel.text = history.first?.uses + } } func updateWidthConstraint(_ constant: CGFloat) { diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json new file mode 100644 index 000000000..9bc42278d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x38", + "green" : "0x29", + "red" : "0x2B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x38", + "green" : "0x29", + "red" : "0x2B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json new file mode 100644 index 000000000..2790f171d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "0x38", + "green" : "0x29", + "red" : "0x2B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 155227685..607fe4390 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -49,6 +49,8 @@ public enum Asset { public static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") public static let disabled = ColorAsset(name: "Colors/Button/disabled") public static let inactive = ColorAsset(name: "Colors/Button/inactive") + public static let tagFollow = ColorAsset(name: "Colors/Button/tagFollow") + public static let tagUnfollow = ColorAsset(name: "Colors/Button/tagUnfollow") } public enum Icon { public static let plus = ColorAsset(name: "Colors/Icon/plus") From b9e4c69576dd112bc237546ee6c97a3955f13915 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 23 Nov 2022 22:38:25 +0100 Subject: [PATCH 24/36] feat: Implement follow/unfollow tag functionality --- .../HashtagTimelineHeaderView.swift | 10 ++- .../HashtagTimelineViewController.swift | 10 +++ .../HashtagTimelineViewModel.swift | 24 ++++++ .../Service/API/APIService+Tags.swift | 49 +++++++++++- .../MastodonSDK/API/Mastodon+API+Tags.swift | 74 ++++++++++++++++++- .../Entity/Mastodon+Entity+Tag.swift | 4 + 6 files changed, 163 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 996c18ffb..2ef1ea95c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -28,6 +28,8 @@ final class HashtagTimelineHeaderView: UIView { private var widthConstraint: NSLayoutConstraint! + var onButtonTapped: (() -> Void)? + let followButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.cornerRadius = 10 @@ -69,7 +71,11 @@ private extension HashtagTimelineHeaderView { postCountDescLabel.text = "posts" participantsDescLabel.text = "participants" postsTodayDescLabel.text = "posts today" - + + followButton.addAction(UIAction(handler: { [weak self] _ in + self?.onButtonTapped?() + }), for: .touchUpInside) + widthConstraint = widthAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ @@ -133,7 +139,7 @@ extension HashtagTimelineHeaderView { postsTodayLabel.text = history.first?.uses } } - + func updateWidthConstraint(_ constant: CGFloat) { widthConstraint.constant = constant } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 8d79b0ed1..698966821 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -177,6 +177,16 @@ extension HashtagTimelineViewController { tableView.tableHeaderView = headerView } headerView.update(tag) + headerView.onButtonTapped = { [weak self] in + switch tag.following { + case .some(false): + self?.viewModel.followTag() + case .some(true): + self?.viewModel.unfollowTag() + default: + break + } + } } override func viewDidLayoutSubviews() { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 86ae80704..f4e7ef428 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -72,6 +72,30 @@ final class HashtagTimelineViewModel { } +extension HashtagTimelineViewModel { + func followTag() { + self.hashtagDetails.send(hashtagDetails.value?.copy(following: true)) + Task { @MainActor in + let tag = try? await context.apiService.followTag( + for: hashtag, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + self.hashtagDetails.send(tag) + } + } + + func unfollowTag() { + self.hashtagDetails.send(hashtagDetails.value?.copy(following: false)) + Task { @MainActor in + let tag = try? await context.apiService.unfollowTag( + for: hashtag, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + self.hashtagDetails.send(tag) + } + } +} + private extension HashtagTimelineViewModel { func updateTagInformation() { Task { @MainActor in diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift index 80e0d083f..0b0320078 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift @@ -21,13 +21,57 @@ extension APIService { let domain = authenticationBox.domain let authorization = authenticationBox.userAuthorization - let response = try await Mastodon.API.Tags.tag( + let response = try await Mastodon.API.Tags.getTagInformation( session: session, domain: domain, tagId: tag, authorization: authorization ).singleOutput() + return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) + } // end func + + public func followTag( + for tag: String, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Tags.followTag( + session: session, + domain: domain, + tagId: tag, + authorization: authorization + ).singleOutput() + + return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) + } // end func + + public func unfollowTag( + for tag: String, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Tags.unfollowTag( + session: session, + domain: domain, + tagId: tag, + authorization: authorization + ).singleOutput() + + return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) + } // end func +} + +fileprivate extension APIService { + func persistTag( + from response: Mastodon.Response.Content, + domain: String, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { let managedObjectContext = self.backgroundManagedObjectContext try await managedObjectContext.performChanges { let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user @@ -44,6 +88,5 @@ extension APIService { } return response - } // end func - + } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift index d8c8f6354..42f8b4fd3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift @@ -14,9 +14,9 @@ extension Mastodon.API.Tags { .appendingPathComponent("tags") } - /// Followed Tags + /// Tags /// - /// View your followed hashtags. + /// View information about a single tag. /// /// - Since: 4.0.0 /// - Version: 4.0.3 @@ -28,7 +28,7 @@ extension Mastodon.API.Tags { /// - authorization: User token /// - tagId: The Hashtag /// - Returns: `AnyPublisher` contains `Tag` nested in the response - public static func tag( + public static func getTagInformation( session: URLSession, domain: String, tagId: String, @@ -46,4 +46,72 @@ extension Mastodon.API.Tags { } .eraseToAnyPublisher() } + + /// Tags + /// + /// Follow a hashtag. + /// + /// - Since: 4.0.0 + /// - Version: 4.0.3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/tags/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - tagId: The Hashtag + /// - Returns: `AnyPublisher` contains `Tag` nested in the response + public static func followTag( + session: URLSession, + domain: String, + tagId: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId) + .appendingPathComponent("follow"), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Tags + /// + /// Unfollow a hashtag. + /// + /// - Since: 4.0.0 + /// - Version: 4.0.3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/tags/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - tagId: The Hashtag + /// - Returns: `AnyPublisher` contains `Tag` nested in the response + public static func unfollowTag( + session: URLSession, + domain: String, + tagId: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId) + .appendingPathComponent("unfollow"), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 9c091d73f..d40bf8915 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -41,5 +41,9 @@ extension Mastodon.Entity { hasher.combine(name) hasher.combine(url) } + + public func copy(following: Bool?) -> Self { + Tag(name: name, url: url, history: history, following: following) + } } } From 855d2cbacd06d18f0ba0bc8f69f9e43dbdab98f7 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 13:07:28 +0100 Subject: [PATCH 25/36] feat: Implement followed tags overview --- Mastodon.xcodeproj/project.pbxproj | 12 ++ Mastodon/Coordinator/SceneCoordinator.swift | 5 + .../HashtagTimelineHeaderView.swift | 61 ++++++--- ...ashtagTimelineHeaderViewActionButton.swift | 38 ++++++ .../HashtagTimelineViewController.swift | 3 +- .../FollowedTagsTableViewCell.swift | 76 +++++++++++ .../FollowedTagsViewController.swift | 33 ++++- .../FollowedTags/FollowedTagsViewModel.swift | 120 ++++++++++++++++++ .../Scene/Profile/ProfileViewController.swift | 3 + .../CoreData 5.xcdatamodel/contents | 3 + .../Entity/Mastodon/MastodonUser.swift | 1 + .../CoreDataStack/Entity/Mastodon/Tag.swift | 43 ++++++- .../CoreDataStack/Tag+Property.swift | 1 + .../FollowedTagsFetchedResultController.swift | 90 +++++++++++++ .../Persistence/Persistence+Tag.swift | 7 + .../Service/API/APIService+Account.swift | 1 + 16 files changed, 473 insertions(+), 24 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift create mode 100644 Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift create mode 100644 Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift create mode 100644 MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2102647ec..e94871b4d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -23,8 +23,11 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; + 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; + 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; 2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; }; + 2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */; }; 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; @@ -522,8 +525,11 @@ 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; + 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; + 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; + 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderViewActionButton.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; @@ -1129,6 +1135,7 @@ 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */, 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */, + 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */, ); path = HashtagTimeline; sourceTree = ""; @@ -1235,6 +1242,8 @@ isa = PBXGroup; children = ( 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */, + 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */, + 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */, ); path = FollowedTags; sourceTree = ""; @@ -3480,6 +3489,7 @@ DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB3E6FE42806A5B800B035AE /* DiscoverySection.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, + 2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */, DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */, @@ -3488,6 +3498,7 @@ DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */, + 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */, DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, @@ -3518,6 +3529,7 @@ 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBEFCD82282A2AB100C0ABEA /* ReportServerRulesView.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, + 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */, C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */, DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */, DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c3019010a..3dc73c020 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -173,6 +173,7 @@ extension SceneCoordinator { case rebloggedBy(viewModel: UserListViewModel) case favoritedBy(viewModel: UserListViewModel) case bookmark(viewModel: BookmarkViewModel) + case followedTags(viewModel: FollowedTagsViewModel) // setting case settings(viewModel: SettingsViewModel) @@ -448,6 +449,10 @@ private extension SceneCoordinator { let _viewController = BookmarkViewController() _viewController.viewModel = viewModel viewController = _viewController + case .followedTags(let viewModel): + let _viewController = FollowedTagsViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .favorite(let viewModel): let _viewController = FavoriteViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 2ef1ea95c..e8cc48e8f 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -6,6 +6,7 @@ // import UIKit +import CoreDataStack import MastodonSDK import MastodonUI import MastodonAsset @@ -16,6 +17,42 @@ fileprivate extension CGFloat { } final class HashtagTimelineHeaderView: UIView { + struct Data { + let name: String + let following: Bool + let postCount: Int + let participantsCount: Int + let postsTodayCount: Int + + static func from(_ entity: Mastodon.Entity.Tag) -> Self { + Data( + name: entity.name, + following: entity.following == true, + postCount: (entity.history ?? []).reduce(0) { res, acc in + res + (Int(acc.uses) ?? 0) + }, + participantsCount: (entity.history ?? []).reduce(0) { res, acc in + res + (Int(acc.accounts) ?? 0) + }, + postsTodayCount: Int(entity.history?.first?.uses ?? "0") ?? 0 + ) + } + + static func from(_ entity: Tag) -> Self { + Data( + name: entity.name, + following: entity.following, + postCount: entity.histories.reduce(0) { res, acc in + res + (Int(acc.uses) ?? 0) + }, + participantsCount: entity.histories.reduce(0) { res, acc in + res + (Int(acc.accounts) ?? 0) + }, + postsTodayCount: Int(entity.histories.first?.uses ?? "0") ?? 0 + ) + } + } + let titleLabel = UILabel() let postCountLabel = UILabel() @@ -31,7 +68,7 @@ final class HashtagTimelineHeaderView: UIView { var onButtonTapped: (() -> Void)? let followButton: UIButton = { - let button = RoundedEdgesButton(type: .custom) + let button = HashtagTimelineHeaderViewActionButton() button.cornerRadius = 10 button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) @@ -76,7 +113,7 @@ private extension HashtagTimelineHeaderView { self?.onButtonTapped?() }), for: .touchUpInside) - widthConstraint = widthAnchor.constraint(equalToConstant: 0) + widthConstraint = widthAnchor.constraint(greaterThanOrEqualToConstant: 0) NSLayoutConstraint.activate([ widthConstraint, @@ -113,7 +150,7 @@ private extension HashtagTimelineHeaderView { } extension HashtagTimelineHeaderView { - func update(_ entity: Mastodon.Entity.Tag) { + func update(_ entity: HashtagTimelineHeaderView.Data) { titleLabel.text = "#\(entity.name)" followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal) @@ -123,21 +160,9 @@ extension HashtagTimelineHeaderView { for: .normal ) - if let history = entity.history { - postCountLabel.text = String( - history.reduce(0) { res, acc in - res + (Int(acc.uses) ?? 0) - } - ) - - participantsLabel.text = String( - history.reduce(0) { res, acc in - res + (Int(acc.accounts) ?? 0) - } - ) - - postsTodayLabel.text = history.first?.uses - } + postCountLabel.text = String(entity.postCount) + participantsLabel.text = String(entity.participantsCount) + postsTodayLabel.text = String(entity.postsTodayCount) } func updateWidthConstraint(_ constant: CGFloat) { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift new file mode 100644 index 000000000..aff97b21c --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift @@ -0,0 +1,38 @@ +// +// HashtagTimelineHeaderViewActionButton.swift +// Mastodon +// +// Created by Marcus Kida on 25.11.22. +// + +import UIKit +import MastodonUI +import MastodonAsset + +class HashtagTimelineHeaderViewActionButton: RoundedEdgesButton { + + init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + + layer.setupShadow( + color: .lightGray, + alpha: 1, + x: 0, + y: 1, + blur: 2, + spread: 0, + roundedRect: bounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: cornerRadius, height: cornerRadius) + ) + } +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 698966821..380098559 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -35,7 +35,6 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me NSLayoutConstraint.activate([ headerView.heightAnchor.constraint(equalToConstant: 118), -// headerView.widthAnchor.constraint(equalTo: tableView.widthAnchor) ]) return headerView @@ -176,7 +175,7 @@ extension HashtagTimelineViewController { if tableView.tableHeaderView == nil { tableView.tableHeaderView = headerView } - headerView.update(tag) + headerView.update(HashtagTimelineHeaderView.Data.from(tag)) headerView.onButtonTapped = { [weak self] in switch tag.following { case .some(false): diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift new file mode 100644 index 000000000..47c856313 --- /dev/null +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift @@ -0,0 +1,76 @@ +// +// FollowedTagsTableViewCell.swift +// Mastodon +// +// Created by Marcus Kida on 24.11.22. +// + +import UIKit +import CoreDataStack + +final class FollowedTagsTableViewCell: UITableViewCell { + private var hashtagView: HashtagTimelineHeaderView! + private let separatorLine = UIView.separatorLine + private weak var viewModel: FollowedTagsViewModel? + private weak var hashtag: Tag? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + override func prepareForReuse() { + viewModel = nil + hashtagView = HashtagTimelineHeaderView() + super.prepareForReuse() + } +} + +private extension FollowedTagsTableViewCell { + func setup() { + selectionStyle = .none + + hashtagView = HashtagTimelineHeaderView() + hashtagView.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(hashtagView) + contentView.backgroundColor = .clear + + NSLayoutConstraint.activate([ + hashtagView.heightAnchor.constraint(equalToConstant: 118).priority(.required), + hashtagView.topAnchor.constraint(equalTo: contentView.topAnchor), + hashtagView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + hashtagView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + hashtagView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + + hashtagView.onButtonTapped = { [weak self] in + guard let self = self, let tag = self.hashtag else { return } + self.viewModel?.followOrUnfollow(tag) + } + } +} + +extension FollowedTagsTableViewCell { + func populate(with tag: Tag) { + self.hashtag = tag + hashtagView.update(HashtagTimelineHeaderView.Data.from(tag)) + } + + func setup(_ viewModel: FollowedTagsViewModel) { + self.viewModel = viewModel + } +} diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift index 2f705dcec..0234c638c 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift @@ -20,12 +20,13 @@ final class FollowedTagsViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - var viewModel: BookmarkViewModel! + var viewModel: FollowedTagsViewModel! + let titleView = DoubleTitleLabelNavigationBarTitleView() + lazy var tableView: UITableView = { let tableView = UITableView() - tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(FollowedTagsTableViewCell.self, forCellReuseIdentifier: String(describing: FollowedTagsTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear @@ -36,3 +37,29 @@ final class FollowedTagsViewController: UIViewController, NeedsDependency { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } + +extension FollowedTagsViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let _title = "Followed Tags" + title = _title + titleView.update(title: _title, subtitle: nil) + + navigationItem.titleView = titleView + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: RunLoop.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + tableView.pinToParent() + viewModel.setupTableView(tableView) + } +} diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift new file mode 100644 index 000000000..ee3a474b8 --- /dev/null +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift @@ -0,0 +1,120 @@ +// +// FollowedTagsViewModel.swift +// Mastodon +// +// Created by Marcus Kida on 23.11.22. +// + +import os +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import MastodonCore + +final class FollowedTagsViewModel: NSObject { + let logger = Logger(subsystem: String(describing: FollowedTagsViewModel.self), category: "ViewModel") + var disposeBag = Set() + let fetchedResultsController: FollowedTagsFetchedResultController + + private weak var tableView: UITableView? + + // input + let context: AppContext + let authContext: AuthContext + + init(context: AppContext, authContext: AuthContext) { + self.context = context + self.authContext = authContext + self.fetchedResultsController = FollowedTagsFetchedResultController( + managedObjectContext: context.managedObjectContext, + domain: authContext.mastodonAuthenticationBox.domain, + user: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)!.user + ) + super.init() + self.fetchedResultsController + .$records + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.tableView?.reloadSections(IndexSet(integer: 0), with: .automatic) + } + .store(in: &disposeBag) + } +} + +extension FollowedTagsViewModel { + func setupTableView(_ tableView: UITableView) { + self.tableView = tableView + tableView.dataSource = self + tableView.delegate = self + + fetchFollowedTags { + tableView.reloadData() + } + } + + func fetchFollowedTags(_ done: @escaping () -> Void) { + Task { @MainActor [weak self] in + try? await self?._fetchFollowedTags() + done() + } + } + + private func _fetchFollowedTags() async throws { + try await context.apiService.getFollowedTags( + domain: authContext.mastodonAuthenticationBox.domain, + query: Mastodon.API.Account.FollowedTagsQuery(limit: nil), + authenticationBox: authContext.mastodonAuthenticationBox + ) + } + + func followOrUnfollow(_ tag: Tag) { + Task { @MainActor in + switch tag.following { + case true: + _ = try? await context.apiService.unfollowTag( + for: tag.name, + authenticationBox: authContext.mastodonAuthenticationBox + ) + case false: + _ = try? await context.apiService.followTag( + for: tag.name, + authenticationBox: authContext.mastodonAuthenticationBox + ) + } + try? await _fetchFollowedTags() + } + } +} + +extension FollowedTagsViewModel: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + fetchedResultsController.records.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard + indexPath.section == 0, + let object = fetchedResultsController.records[indexPath.row].object(in: context.managedObjectContext) + else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FollowedTagsTableViewCell.self), for: indexPath) as! FollowedTagsTableViewCell + + cell.setup(self) + cell.populate(with: object) + return cell + } +} + +extension FollowedTagsViewModel: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 62aa8e57a..13012f83b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -560,6 +560,9 @@ extension ProfileViewController { @objc private func followedTagsItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let followedTagsViewModel = FollowedTagsViewModel(context: context, authContext: viewModel.authContext) + _ = coordinator.present(scene: .followedTags(viewModel: followedTagsViewModel), from: self, transition: .show) } @objc private func refreshControlValueChanged(_ sender: RefreshControl) { diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents index 69ffac191..9f07cca78 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 5.xcdatamodel/contents @@ -93,6 +93,7 @@ + @@ -245,11 +246,13 @@ + + \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index 760985d68..fe668ccb9 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -77,6 +77,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var votePollOptions: Set @NSManaged public private(set) var votePolls: Set // relationships + @NSManaged public private(set) var followedTags: Set @NSManaged public private(set) var following: Set @NSManaged public private(set) var followingBy: Set @NSManaged public private(set) var followRequested: Set diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift index b5c335db3..51826fe26 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift @@ -24,10 +24,13 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var name: String // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var url: String - + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var following: Bool + // one-to-one relationship // many-to-many relationship + @NSManaged public private(set) var followedBy: Set // one-to-many relationship @NSManaged public private(set) var searchHistories: Set @@ -91,12 +94,27 @@ public extension Tag { NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) } + static func predicate(domain: String, following: Bool) -> NSPredicate { + NSPredicate(format: "%K == %@ AND %K == %d", #keyPath(Tag.domain), domain, #keyPath(Tag.following), following) + } + + static func predicate(followedBy user: MastodonUser) -> NSPredicate { + NSPredicate(format: "ANY %K.%K == %@", #keyPath(Tag.followedBy), #keyPath(MastodonUser.id), user.id) + } + static func predicate(domain: String, name: String) -> NSPredicate { NSCompoundPredicate(andPredicateWithSubpredicates: [ predicate(domain: domain), predicate(name: name), ]) } + + static func predicate(domain: String, following: Bool, by user: MastodonUser) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain, following: following), + predicate(followedBy: user) + ]) + } } // MARK: - AutoGenerateProperty @@ -112,6 +130,7 @@ extension Tag: AutoGenerateProperty { public let updatedAt: Date public let name: String public let url: String + public let following: Bool public let histories: [MastodonTagHistory] public init( @@ -121,6 +140,7 @@ extension Tag: AutoGenerateProperty { updatedAt: Date, name: String, url: String, + following: Bool, histories: [MastodonTagHistory] ) { self.identifier = identifier @@ -129,6 +149,7 @@ extension Tag: AutoGenerateProperty { self.updatedAt = updatedAt self.name = name self.url = url + self.following = following self.histories = histories } } @@ -140,12 +161,14 @@ extension Tag: AutoGenerateProperty { self.updatedAt = property.updatedAt self.name = property.name self.url = property.url + self.following = property.following self.histories = property.histories } public func update(property: Property) { update(updatedAt: property.updatedAt) update(url: property.url) + update(following: property.following) update(histories: property.histories) } // sourcery:end @@ -167,12 +190,30 @@ extension Tag: AutoUpdatableObject { self.url = url } } + public func update(following: Bool) { + if self.following != following { + self.following = following + } + } public func update(histories: [MastodonTagHistory]) { if self.histories != histories { self.histories = histories } } // sourcery:end + + public func update(followed: Bool, by mastodonUser: MastodonUser) { + if following { + if !self.followedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).add(mastodonUser) + } + } else { + if self.followedBy.contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).remove(mastodonUser) + } + } + } + } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift index 633f7bddf..7411fd960 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift @@ -22,6 +22,7 @@ extension Tag.Property { updatedAt: networkDate, name: entity.name, url: entity.url, + following: entity.following ?? false, histories: { guard let histories = entity.history else { return [] } let result: [MastodonTagHistory] = histories.map { history in diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift new file mode 100644 index 000000000..56c8a043b --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift @@ -0,0 +1,90 @@ +// +// FollowedTagsFetchedResultController.swift +// +// +// Created by Marcus Kida on 23.11.22. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +public final class FollowedTagsFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + @Published public var domain: String? = nil + @Published public var user: MastodonUser? = nil + + // output + let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + @Published public private(set) var records: [ManagedObjectRecord] = [] + + public init(managedObjectContext: NSManagedObjectContext, domain: String, user: MastodonUser) { + self.domain = domain + self.fetchedResultsController = { + let fetchRequest = Tag.sortedFetchRequest + fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + // debounce output to prevent UI update issues + _objectIDs + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } + .assign(to: &$records) + + fetchedResultsController.delegate = self + try? fetchedResultsController.performFetch() + + Publishers.CombineLatest( + self.$domain, + self.$user + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, user in + guard let self = self, let domain = domain, let user = user else { return } + self.fetchedResultsController.fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension FollowedTagsFetchedResultController: NSFetchedResultsControllerDelegate { + public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + 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.objectID } + self._objectIDs.value = items + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift index 5c7130618..3071fed0d 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift @@ -103,6 +103,9 @@ extension Persistence.Tag { property: property ) update(tag: object, context: context) + if let followingUser = context.me { + object.update(followed: property.following, by: followingUser) + } return object } @@ -116,7 +119,11 @@ extension Persistence.Tag { domain: context.domain, networkDate: context.networkDate ) + tag.update(property: property) + if let followingUser = context.me { + tag.update(followed: property.following, by: followingUser) + } update(tag: tag, context: context) } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index c27f8cf67..8f89b4b76 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -163,6 +163,7 @@ extension APIService { } extension APIService { + @discardableResult public func getFollowedTags( domain: String, query: Mastodon.API.Account.FollowedTagsQuery, From ba26dd207625a6a7621a2db5e69dc1ad5567bc74 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 13:43:20 +0100 Subject: [PATCH 26/36] feat: Implement navigating followed tags --- .../FollowedTagsTableViewCell.swift | 4 +++- .../FollowedTagsViewController.swift | 12 ++++++++++++ .../FollowedTags/FollowedTagsViewModel.swift | 18 ++++++++++++++++++ .../FollowedTagsFetchedResultController.swift | 13 ++++--------- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift index 47c856313..6adb15a9c 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsTableViewCell.swift @@ -24,9 +24,11 @@ final class FollowedTagsTableViewCell: UITableViewCell { } override func prepareForReuse() { + hashtagView.removeFromSuperview() viewModel = nil - hashtagView = HashtagTimelineHeaderView() + hashtagView = nil super.prepareForReuse() + setup() } } diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift index 0234c638c..6ff3afbcf 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift @@ -61,5 +61,17 @@ extension FollowedTagsViewController { view.addSubview(tableView) tableView.pinToParent() viewModel.setupTableView(tableView) + + viewModel.presentHashtagTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] hashtagTimelineViewModel in + guard let self = self else { return } + _ = self.coordinator.present( + scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), + from: self, + transition: .show + ) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift index ee3a474b8..e0f1b326a 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift @@ -24,6 +24,9 @@ final class FollowedTagsViewModel: NSObject { let context: AppContext let authContext: AuthContext + // output + let presentHashtagTimeline = PassthroughSubject() + init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext @@ -115,6 +118,21 @@ extension FollowedTagsViewModel: UITableViewDataSource { extension FollowedTagsViewModel: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)") tableView.deselectRow(at: indexPath, animated: true) + + guard + indexPath.section == 0, + let object = fetchedResultsController.records[indexPath.row].object(in: context.managedObjectContext) + else { + return + } + + let hashtagTimelineViewModel = HashtagTimelineViewModel( + context: self.context, + authContext: self.authContext, + hashtag: object.name + ) + presentHashtagTimeline.send(hashtagTimelineViewModel) } } diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift index 56c8a043b..1398cc7f6 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift @@ -31,6 +31,7 @@ public final class FollowedTagsFetchedResultController: NSObject { self.fetchedResultsController = { let fetchRequest = Tag.sortedFetchRequest fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user) + fetchRequest.sortDescriptors = Tag.defaultSortDescriptors fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( @@ -74,17 +75,11 @@ public final class FollowedTagsFetchedResultController: NSObject { // MARK: - NSFetchedResultsControllerDelegate extension FollowedTagsFetchedResultController: NSFetchedResultsControllerDelegate { - public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) 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.objectID } - self._objectIDs.value = items + self._objectIDs.value = objects.map { $0.objectID } + } } From f112e68e7925de614b95b8099549d1f0e9a08db2 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 14:16:58 +0100 Subject: [PATCH 27/36] chore: Add L10n for Followed Tags --- Localization/app.json | 4 ++++ .../Profile/FollowedTags/FollowedTagsViewController.swift | 2 +- Mastodon/Scene/Profile/ProfileViewController.swift | 2 +- .../Sources/MastodonLocalization/Generated/Strings.swift | 4 ++++ .../Resources/Base.lproj/Localizable.strings | 3 ++- .../Resources/en.lproj/Localizable.strings | 3 ++- 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index ea046bfbc..56d4fa9ed 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -722,6 +722,10 @@ }, "bookmark": { "title": "Bookmarks" + + }, + "followed_tags": { + "title": "Followed Tags" } } } diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift index 6ff3afbcf..9a31fe0c3 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewController.swift @@ -42,7 +42,7 @@ extension FollowedTagsViewController { override func viewDidLoad() { super.viewDidLoad() - let _title = "Followed Tags" + let _title = L10n.Scene.FollowedTags.title title = _title titleView.update(title: _title, subtitle: nil) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 13012f83b..1ee13457b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -108,7 +108,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi private(set) lazy var followedTagsBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "number"), style: .plain, target: self, action: #selector(ProfileViewController.followedTagsItemPressed(_:))) barButtonItem.tintColor = .white - //barButtonItem.accessibilityLabel = "" TODO: add missing L10n.Common.Controls.Actions.XXXXXX + barButtonItem.accessibilityLabel = L10n.Scene.FollowedTags.title return barButtonItem }() diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 0392d2b05..5b62bf9c1 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -633,6 +633,10 @@ public enum L10n { /// Favorited By public static let title = L10n.tr("Localizable", "Scene.FavoritedBy.Title", fallback: "Favorited By") } + public enum FollowedTags { + /// Followed Tags + public static let title = L10n.tr("Localizable", "Scene.FollowedTags.Title", fallback: "Followed Tags") + } public enum Follower { /// Followers from other servers are not displayed. public static let footer = L10n.tr("Localizable", "Scene.Follower.Footer", fallback: "Followers from other servers are not displayed.") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index adeaad07b..c04e6eb83 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -231,6 +231,7 @@ uploaded to Mastodon."; "Scene.FavoritedBy.Title" = "Favorited By"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; +"Scene.FollowedTags.Title" = "Followed Tags"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; @@ -461,4 +462,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 2a3f1efbf..71443d816 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -231,6 +231,7 @@ uploaded to Mastodon."; "Scene.FavoritedBy.Title" = "Favorited By"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; +"Scene.FollowedTags.Title" = "Followed Tags"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; @@ -461,4 +462,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; From 6a9b29e4a6d04959d40033b7f11c7dcb698ef2e3 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 15:43:02 +0100 Subject: [PATCH 28/36] chore: Add missing L10n --- Localization/app.json | 11 ++++++++++- .../HashtagTimelineHeaderView.swift | 8 ++++---- .../MastodonLocalization/Generated/Strings.swift | 14 ++++++++++++++ .../Resources/Base.lproj/Localizable.strings | 5 +++++ .../Resources/en.lproj/Localizable.strings | 5 +++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 56d4fa9ed..f3225e58c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -725,7 +725,16 @@ }, "followed_tags": { - "title": "Followed Tags" + "title": "Followed Tags", + "header": { + "posts": "posts", + "participants": "participants", + "posts_today": "posts today" + }, + "actions": { + "follow": "Follow", + "unfollow": "Unfollow" + } } } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index e8cc48e8f..1b64a1a48 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -105,9 +105,9 @@ private extension HashtagTimelineHeaderView { $0.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular)) } - postCountDescLabel.text = "posts" - participantsDescLabel.text = "participants" - postsTodayDescLabel.text = "posts today" + postCountDescLabel.text = L10n.Scene.FollowedTags.Header.posts + participantsDescLabel.text = L10n.Scene.FollowedTags.Header.participants + postsTodayDescLabel.text = L10n.Scene.FollowedTags.Header.postsToday followButton.addAction(UIAction(handler: { [weak self] _ in self?.onButtonTapped?() @@ -152,7 +152,7 @@ private extension HashtagTimelineHeaderView { extension HashtagTimelineHeaderView { func update(_ entity: HashtagTimelineHeaderView.Data) { titleLabel.text = "#\(entity.name)" - followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal) + followButton.setTitle(entity.following == true ? L10n.Scene.FollowedTags.Actions.unfollow : L10n.Scene.FollowedTags.Actions.follow, for: .normal) followButton.backgroundColor = entity.following == true ? Asset.Colors.Button.tagUnfollow.color : Asset.Colors.Button.tagFollow.color followButton.setTitleColor( diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 5b62bf9c1..34c2b350e 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -636,6 +636,20 @@ public enum L10n { public enum FollowedTags { /// Followed Tags public static let title = L10n.tr("Localizable", "Scene.FollowedTags.Title", fallback: "Followed Tags") + public enum Actions { + /// follow + public static let follow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Follow", fallback: "follow") + } + public enum Header { + /// participants + public static let participants = L10n.tr("Localizable", "Scene.FollowedTags.Header.Participants", fallback: "participants") + /// posts + public static let posts = L10n.tr("Localizable", "Scene.FollowedTags.Header.Posts", fallback: "posts") + /// posts today + public static let postsToday = L10n.tr("Localizable", "Scene.FollowedTags.Header.PostsToday", fallback: "posts today") + /// unfollow + public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Header.Unfollow", fallback: "unfollow") + } } public enum Follower { /// Followers from other servers are not displayed. diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index c04e6eb83..6882b0fc5 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -232,6 +232,11 @@ uploaded to Mastodon."; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; "Scene.FollowedTags.Title" = "Followed Tags"; +"Scene.FollowedTags.Header.Posts" = "posts"; +"Scene.FollowedTags.Header.Participants" = "participants"; +"Scene.FollowedTags.Header.PostsToday" = "posts today"; +"Scene.FollowedTags.Actions.Follow" = "follow"; +"Scene.FollowedTags.Header.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 71443d816..35509900f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -232,6 +232,11 @@ uploaded to Mastodon."; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; "Scene.FollowedTags.Title" = "Followed Tags"; +"Scene.FollowedTags.Header.Posts" = "posts"; +"Scene.FollowedTags.Header.Participants" = "participants"; +"Scene.FollowedTags.Header.PostsToday" = "posts today"; +"Scene.FollowedTags.Actions.Follow" = "follow"; +"Scene.FollowedTags.Header.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; From 0c571a2df69eabf2e6165faf3a11089b315c936b Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 25 Nov 2022 18:47:49 +0100 Subject: [PATCH 29/36] fix: Localizable string issues --- .../HashtagTimelineHeaderView.swift | 18 ++++++++++++++---- .../Generated/Strings.swift | 4 ++-- .../Resources/Base.lproj/Localizable.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 1b64a1a48..fac856dc2 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -10,6 +10,7 @@ import CoreDataStack import MastodonSDK import MastodonUI import MastodonAsset +import MastodonLocalization fileprivate extension CGFloat { static let padding: CGFloat = 16 @@ -155,10 +156,19 @@ extension HashtagTimelineHeaderView { followButton.setTitle(entity.following == true ? L10n.Scene.FollowedTags.Actions.unfollow : L10n.Scene.FollowedTags.Actions.follow, for: .normal) followButton.backgroundColor = entity.following == true ? Asset.Colors.Button.tagUnfollow.color : Asset.Colors.Button.tagFollow.color - followButton.setTitleColor( - entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color, - for: .normal - ) + + switch traitCollection.userInterfaceStyle { + case .dark: + followButton.setTitleColor( + .lightGray, + for: .normal + ) + default: + followButton.setTitleColor( + entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color, + for: .normal + ) + } postCountLabel.text = String(entity.postCount) participantsLabel.text = String(entity.participantsCount) diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 34c2b350e..5bed76fce 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -639,6 +639,8 @@ public enum L10n { public enum Actions { /// follow public static let follow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Follow", fallback: "follow") + /// unfollow + public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Unfollow", fallback: "unfollow") } public enum Header { /// participants @@ -647,8 +649,6 @@ public enum L10n { public static let posts = L10n.tr("Localizable", "Scene.FollowedTags.Header.Posts", fallback: "posts") /// posts today public static let postsToday = L10n.tr("Localizable", "Scene.FollowedTags.Header.PostsToday", fallback: "posts today") - /// unfollow - public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Header.Unfollow", fallback: "unfollow") } } public enum Follower { diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 6882b0fc5..2fb57c1b3 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -236,7 +236,7 @@ uploaded to Mastodon."; "Scene.FollowedTags.Header.Participants" = "participants"; "Scene.FollowedTags.Header.PostsToday" = "posts today"; "Scene.FollowedTags.Actions.Follow" = "follow"; -"Scene.FollowedTags.Header.Unfollow" = "unfollow"; +"Scene.FollowedTags.Actions.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 35509900f..3d1f4c0a4 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -236,7 +236,7 @@ uploaded to Mastodon."; "Scene.FollowedTags.Header.Participants" = "participants"; "Scene.FollowedTags.Header.PostsToday" = "posts today"; "Scene.FollowedTags.Actions.Follow" = "follow"; -"Scene.FollowedTags.Header.Unfollow" = "unfollow"; +"Scene.FollowedTags.Actions.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; From 75dc530dcf5a55b95653e99b2c0f241679fd482e Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 1 Dec 2022 11:39:02 +0100 Subject: [PATCH 30/36] chore: Implement FollowedTagsViewModel+DiffableDataSource --- Mastodon.xcodeproj/project.pbxproj | 4 + ...owedTagsViewModel+DiffableDataSource.swift | 51 ++++++++++++ .../FollowedTags/FollowedTagsViewModel.swift | 79 ++++++------------- .../FollowedTagsFetchedResultController.swift | 16 +--- 4 files changed, 84 insertions(+), 66 deletions(-) create mode 100644 Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel+DiffableDataSource.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e94871b4d..c5375b7a0 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; + 2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; @@ -525,6 +526,7 @@ 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; + 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = ""; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; @@ -1243,6 +1245,7 @@ children = ( 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */, 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */, + 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */, 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */, ); path = FollowedTags; @@ -3398,6 +3401,7 @@ DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DB6988DE2848D11C002398EF /* PagerTabStripNavigateable.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, + 2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */, DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */, DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel+DiffableDataSource.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel+DiffableDataSource.swift new file mode 100644 index 000000000..91f2cc5e9 --- /dev/null +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel+DiffableDataSource.swift @@ -0,0 +1,51 @@ +// +// FollowedTagsViewModel+DiffableDataSource.swift +// Mastodon +// +// Created by Marcus Kida on 01.12.22. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import MastodonCore + +extension FollowedTagsViewModel { + enum Section: Hashable { + case main + } + + enum Item: Hashable { + case hashtag(Tag) + } + + func tableViewDiffableDataSource( + for tableView: UITableView + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case let .hashtag(tag): + guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FollowedTagsTableViewCell.self), for: indexPath) as? FollowedTagsTableViewCell else { + assertionFailure() + return UITableViewCell() + } + + cell.setup(self) + cell.populate(with: tag) + return cell + } + } + } + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = tableViewDiffableDataSource(for: tableView) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift index e0f1b326a..75464092e 100644 --- a/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift +++ b/Mastodon/Scene/Profile/FollowedTags/FollowedTagsViewModel.swift @@ -19,7 +19,8 @@ final class FollowedTagsViewModel: NSObject { let fetchedResultsController: FollowedTagsFetchedResultController private weak var tableView: UITableView? - + var diffableDataSource: UITableViewDiffableDataSource? + // input let context: AppContext let authContext: AuthContext @@ -35,12 +36,18 @@ final class FollowedTagsViewModel: NSObject { domain: authContext.mastodonAuthenticationBox.domain, user: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)!.user ) + super.init() + self.fetchedResultsController .$records .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.tableView?.reloadSections(IndexSet(integer: 0), with: .automatic) + .sink { [weak self] records in + guard let self = self else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(records.map {.hashtag($0) }) + self.diffableDataSource?.applySnapshot(snapshot, animated: true) } .store(in: &disposeBag) } @@ -49,29 +56,22 @@ final class FollowedTagsViewModel: NSObject { extension FollowedTagsViewModel { func setupTableView(_ tableView: UITableView) { self.tableView = tableView - tableView.dataSource = self + setupDiffableDataSource(tableView: tableView) tableView.delegate = self - fetchFollowedTags { - tableView.reloadData() + fetchFollowedTags() + } + + func fetchFollowedTags() { + Task { @MainActor in + try await context.apiService.getFollowedTags( + domain: authContext.mastodonAuthenticationBox.domain, + query: Mastodon.API.Account.FollowedTagsQuery(limit: nil), + authenticationBox: authContext.mastodonAuthenticationBox + ) } } - - func fetchFollowedTags(_ done: @escaping () -> Void) { - Task { @MainActor [weak self] in - try? await self?._fetchFollowedTags() - done() - } - } - - private func _fetchFollowedTags() async throws { - try await context.apiService.getFollowedTags( - domain: authContext.mastodonAuthenticationBox.domain, - query: Mastodon.API.Account.FollowedTagsQuery(limit: nil), - authenticationBox: authContext.mastodonAuthenticationBox - ) - } - + func followOrUnfollow(_ tag: Tag) { Task { @MainActor in switch tag.following { @@ -86,53 +86,24 @@ extension FollowedTagsViewModel { authenticationBox: authContext.mastodonAuthenticationBox ) } - try? await _fetchFollowedTags() + fetchFollowedTags() } } } -extension FollowedTagsViewModel: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - fetchedResultsController.records.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard - indexPath.section == 0, - let object = fetchedResultsController.records[indexPath.row].object(in: context.managedObjectContext) - else { - return UITableViewCell() - } - - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FollowedTagsTableViewCell.self), for: indexPath) as! FollowedTagsTableViewCell - - cell.setup(self) - cell.populate(with: object) - return cell - } -} - extension FollowedTagsViewModel: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)") tableView.deselectRow(at: indexPath, animated: true) - guard - indexPath.section == 0, - let object = fetchedResultsController.records[indexPath.row].object(in: context.managedObjectContext) - else { - return - } + let object = fetchedResultsController.records[indexPath.row] let hashtagTimelineViewModel = HashtagTimelineViewModel( context: self.context, authContext: self.authContext, hashtag: object.name ) + presentHashtagTimeline.send(hashtagTimelineViewModel) } } diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift index 1398cc7f6..8c5c64b58 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift @@ -23,8 +23,7 @@ public final class FollowedTagsFetchedResultController: NSObject { @Published public var user: MastodonUser? = nil // output - let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - @Published public private(set) var records: [ManagedObjectRecord] = [] + @Published public private(set) var records: [Tag] = [] public init(managedObjectContext: NSManagedObjectContext, domain: String, user: MastodonUser) { self.domain = domain @@ -44,13 +43,7 @@ public final class FollowedTagsFetchedResultController: NSObject { return controller }() super.init() - - // debounce output to prevent UI update issues - _objectIDs - .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) - .map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } } - .assign(to: &$records) - + fetchedResultsController.delegate = self try? fetchedResultsController.performFetch() @@ -75,11 +68,10 @@ public final class FollowedTagsFetchedResultController: NSObject { // MARK: - NSFetchedResultsControllerDelegate extension FollowedTagsFetchedResultController: NSFetchedResultsControllerDelegate { - public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) let objects = fetchedResultsController.fetchedObjects ?? [] - self._objectIDs.value = objects.map { $0.objectID } - + self.records = objects } } From 270bb6a1ed32d845447a88d3ad8124014622b560 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 1 Dec 2022 12:38:23 +0100 Subject: [PATCH 31/36] fix: Update HashtagTimelineHeaderView if follow state changes --- Mastodon.xcodeproj/project.pbxproj | 4 ++++ Mastodon/Extension/Collection+IsNotEmpty.swift | 14 ++++++++++++++ .../HashtagTimelineViewController.swift | 1 + .../HashtagTimeline/HashtagTimelineViewModel.swift | 14 ++++++++++++++ .../CoreDataStack/Entity/Mastodon/Tag.swift | 3 ++- 5 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 Mastodon/Extension/Collection+IsNotEmpty.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c5375b7a0..96404cebb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; }; + 2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; @@ -527,6 +528,7 @@ 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = ""; }; + 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = ""; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; @@ -2275,6 +2277,7 @@ DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */, DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */, + 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */, ); path = Extension; sourceTree = ""; @@ -3322,6 +3325,7 @@ 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */, DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */, + 2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, 6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */, 5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */, diff --git a/Mastodon/Extension/Collection+IsNotEmpty.swift b/Mastodon/Extension/Collection+IsNotEmpty.swift new file mode 100644 index 000000000..d59b8e2fe --- /dev/null +++ b/Mastodon/Extension/Collection+IsNotEmpty.swift @@ -0,0 +1,14 @@ +// +// Array+IsNotEmpty.swift +// Mastodon +// +// Created by Marcus Kida on 01.12.22. +// + +import Foundation + +extension Collection { + var isNotEmpty: Bool { + !isEmpty + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 380098559..b1e4ad40a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -139,6 +139,7 @@ extension HashtagTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + viewModel.viewWillAppear() tableView.deselectRow(with: transitionCoordinator, animated: animated) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index f4e7ef428..888bc720c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -70,6 +70,20 @@ final class HashtagTimelineViewModel { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } + func viewWillAppear() { + let predicate = Tag.predicate( + domain: authContext.mastodonAuthenticationBox.domain, + name: hashtag + ) + + guard + let object = Tag.findOrFetch(in: context.managedObjectContext, matching: predicate) + else { + return hashtagDetails.send(hashtagDetails.value?.copy(following: false)) + } + + hashtagDetails.send(hashtagDetails.value?.copy(following: object.following)) + } } extension HashtagTimelineViewModel { diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift index 51826fe26..cd9799034 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift @@ -91,7 +91,8 @@ public extension Tag { } static func predicate(name: String) -> NSPredicate { - NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) + // use case-insensitive query as tags #CaN #BE #speLLed #USiNG #arbITRARy #cASe + NSPredicate(format: "%K contains[c] %@", #keyPath(Tag.name), name) } static func predicate(domain: String, following: Bool) -> NSPredicate { From bf5a0917988620956aa69eed60de502b3a95013b Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 1 Dec 2022 14:24:54 +0100 Subject: [PATCH 32/36] chore: Improve Tag.predicate(name:) case-insensitivity --- MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift index cd9799034..8332f3d4c 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift @@ -92,7 +92,7 @@ public extension Tag { static func predicate(name: String) -> NSPredicate { // use case-insensitive query as tags #CaN #BE #speLLed #USiNG #arbITRARy #cASe - NSPredicate(format: "%K contains[c] %@", #keyPath(Tag.name), name) + NSPredicate(format: "%K MATCHES[c] %@", #keyPath(Tag.name), name) } static func predicate(domain: String, following: Bool) -> NSPredicate { From 324e782fd5e04434e22b4a9a52a08bd3d33e4241 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 1 Dec 2022 14:31:56 +0100 Subject: [PATCH 33/36] chore: Improve Hashtag Header Post Count label alignment --- .../Scene/HashtagTimeline/HashtagTimelineHeaderView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index fac856dc2..998b774b6 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -124,8 +124,8 @@ private extension HashtagTimelineHeaderView { titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding), postCountLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding), - postCountLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), - postCountDescLabel.leadingAnchor.constraint(equalTo: postCountLabel.leadingAnchor), + postCountLabel.centerXAnchor.constraint(equalTo: postCountDescLabel.centerXAnchor), + postCountDescLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), participantsDescLabel.leadingAnchor.constraint(equalTo: postCountDescLabel.trailingAnchor, constant: .descriptionLabelSpacing), participantsLabel.centerXAnchor.constraint(equalTo: participantsDescLabel.centerXAnchor), From 6c2fabaa039676821c33c62f1a7d5a8fbddb6195 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 1 Dec 2022 14:52:55 +0100 Subject: [PATCH 34/36] chore: Fix hashtag follow button style --- .../HashtagTimelineHeaderView.swift | 16 ++++------------ .../HashtagTimelineHeaderViewActionButton.swift | 11 ++++++++++- .../Button/tagFollow.colorset/Contents.json | 8 ++++---- .../Button/tagUnfollow.colorset/Contents.json | 2 +- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 998b774b6..3af2cf07b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -157,18 +157,10 @@ extension HashtagTimelineHeaderView { followButton.backgroundColor = entity.following == true ? Asset.Colors.Button.tagUnfollow.color : Asset.Colors.Button.tagFollow.color - switch traitCollection.userInterfaceStyle { - case .dark: - followButton.setTitleColor( - .lightGray, - for: .normal - ) - default: - followButton.setTitleColor( - entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color, - for: .normal - ) - } + followButton.setTitleColor( + entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color, + for: .normal + ) postCountLabel.text = String(entity.postCount) participantsLabel.text = String(entity.participantsCount) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift index aff97b21c..7efeb15d0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderViewActionButton.swift @@ -22,8 +22,17 @@ class HashtagTimelineHeaderViewActionButton: RoundedEdgesButton { public override func layoutSubviews() { super.layoutSubviews() + let shadowColor: UIColor = { + switch traitCollection.userInterfaceStyle { + case .dark: + return .darkGray + default: + return .lightGray + } + }() + layer.setupShadow( - color: .lightGray, + color: shadowColor, alpha: 1, x: 0, y: 1, diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json index 9bc42278d..e7e41c6ff 100644 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagFollow.colorset/Contents.json @@ -22,10 +22,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.700", - "blue" : "0x38", - "green" : "0x29", - "red" : "0x2B" + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json index 2790f171d..e7fbb60a6 100644 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Button/tagUnfollow.colorset/Contents.json @@ -22,7 +22,7 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.250", + "alpha" : "1.000", "blue" : "0x38", "green" : "0x29", "red" : "0x2B" From c92468a70615a5a371c93637085870afb89c81b8 Mon Sep 17 00:00:00 2001 From: David Godfrey Date: Fri, 2 Dec 2022 08:29:30 +0000 Subject: [PATCH 35/36] fix: Tidy up accessibility labels in bio fields Enables reading out the label for the checkmark, and avoids describing the title text as a 'button'. --- .../Profile/About/Cell/ProfileFieldCollectionViewCell.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift index 1ed76a485..068da2ed5 100644 --- a/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift +++ b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift @@ -68,6 +68,11 @@ extension ProfileFieldCollectionViewCell { checkmark.addInteraction(editMenuInteraction) } + // Setup Accessibility + checkmark.isAccessibilityElement = true + checkmark.accessibilityTraits = .none + keyMetaLabel.accessibilityTraits = .none + // containerStackView: V - [ metaContainer | plainContainer ] let containerStackView = UIStackView() containerStackView.axis = .vertical From a6ff6e7cec0f160dd8eee953bac8e638400d171d Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Fri, 2 Dec 2022 11:06:15 +0100 Subject: [PATCH 36/36] chore: Update localizable strings for follow tags --- .../StringsConvertor/input/Base.lproj/app.json | 13 +++++++++++++ .../MastodonLocalization/Generated/Strings.swift | 8 ++++---- .../Resources/Base.lproj/Localizable.strings | 14 +++++++------- .../Resources/en.lproj/Localizable.strings | 8 +------- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index df1a45fe1..c4a701948 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -722,6 +722,19 @@ }, "bookmark": { "title": "Bookmarks" + + }, + "followed_tags": { + "title": "Followed Tags", + "header": { + "posts": "posts", + "participants": "participants", + "posts_today": "posts today" + }, + "actions": { + "follow": "Follow", + "unfollow": "Unfollow" + } } } } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index a521cd0fb..bafed05f6 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -637,10 +637,10 @@ public enum L10n { /// Followed Tags public static let title = L10n.tr("Localizable", "Scene.FollowedTags.Title", fallback: "Followed Tags") public enum Actions { - /// follow - public static let follow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Follow", fallback: "follow") - /// unfollow - public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Unfollow", fallback: "unfollow") + /// Follow + public static let follow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Follow", fallback: "Follow") + /// Unfollow + public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Unfollow", fallback: "Unfollow") } public enum Header { /// participants diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index f8cc50a36..5204a1176 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -229,14 +229,14 @@ uploaded to Mastodon."; "Scene.Familiarfollowers.Title" = "Followers you familiar"; "Scene.Favorite.Title" = "Your Favorites"; "Scene.FavoritedBy.Title" = "Favorited By"; +"Scene.FollowedTags.Actions.Follow" = "Follow"; +"Scene.FollowedTags.Actions.Unfollow" = "Unfollow"; +"Scene.FollowedTags.Header.Participants" = "participants"; +"Scene.FollowedTags.Header.Posts" = "posts"; +"Scene.FollowedTags.Header.PostsToday" = "posts today"; +"Scene.FollowedTags.Title" = "Followed Tags"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; -"Scene.FollowedTags.Title" = "Followed Tags"; -"Scene.FollowedTags.Header.Posts" = "posts"; -"Scene.FollowedTags.Header.Participants" = "participants"; -"Scene.FollowedTags.Header.PostsToday" = "posts today"; -"Scene.FollowedTags.Actions.Follow" = "follow"; -"Scene.FollowedTags.Actions.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; @@ -467,4 +467,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 3d1f4c0a4..2a3f1efbf 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -231,12 +231,6 @@ uploaded to Mastodon."; "Scene.FavoritedBy.Title" = "Favorited By"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; -"Scene.FollowedTags.Title" = "Followed Tags"; -"Scene.FollowedTags.Header.Posts" = "posts"; -"Scene.FollowedTags.Header.Participants" = "participants"; -"Scene.FollowedTags.Header.PostsToday" = "posts today"; -"Scene.FollowedTags.Actions.Follow" = "follow"; -"Scene.FollowedTags.Actions.Unfollow" = "unfollow"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; @@ -467,4 +461,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; +"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file