From 30b2a35b84d685f2def2b1c8cfbb2181bd6918bf Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Nov 2021 16:12:20 +0800 Subject: [PATCH] feat: implement following list --- Mastodon.xcodeproj/project.pbxproj | 4 + .../xcschemes/xcschememanagement.plist | 8 +- .../FollowerListViewModel+State.swift | 2 +- .../FollowingListViewModel+State.swift | 2 +- .../APIService/APIService+Following.swift | 70 ++++++++++++++++ .../API/Mastodon+API+Account+Following.swift | 82 +++++++++++++++++++ 6 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+Following.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8408f3ac3..98cff7ec9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -320,6 +320,7 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB67D08427312970006A36CF /* APIService+Following.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D08327312970006A36CF /* APIService+Following.swift */; }; DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; @@ -1139,6 +1140,7 @@ DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = ""; }; DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; @@ -2326,6 +2328,7 @@ 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DB6B74F9272FC2B500C70B6E /* APIService+Follower.swift */, + DB67D08327312970006A36CF /* APIService+Following.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, 5B24BBE1262DB19100A9381B /* APIService+Report.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, @@ -4125,6 +4128,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, DB73BF43271192BB00781945 /* InstanceService.swift in Sources */, + DB67D08427312970006A36CF /* APIService+Following.swift in Sources */, DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 2d2dd1932..5a5418919 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ AppShared.xcscheme_^#shared#^_ orderHint - 36 + 37 CoreDataStack.xcscheme_^#shared#^_ orderHint - 35 + 36 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -97,7 +97,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 38 + 35 MastodonIntents.xcscheme_^#shared#^_ @@ -117,7 +117,7 @@ ShareActionExtension.xcscheme_^#shared#^_ orderHint - 37 + 38 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift index 30621f6a3..43e532673 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel+State.swift @@ -155,7 +155,7 @@ extension FollowerListViewModel.State { let maxID = response.link?.maxID - if maxID != nil { + if hasNewAppend && maxID != nil { stateMachine.enter(Idle.self) } else { stateMachine.enter(NoMore.self) diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift index e6fadf7f8..0ec3d6262 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewModel+State.swift @@ -128,7 +128,7 @@ extension FollowingListViewModel.State { return } - viewModel.context.apiService.followers( + viewModel.context.apiService.following( userID: userID, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox diff --git a/Mastodon/Service/APIService/APIService+Following.swift b/Mastodon/Service/APIService/APIService+Following.swift new file mode 100644 index 000000000..8f477d6ec --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Following.swift @@ -0,0 +1,70 @@ +// +// APIService+Following.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func following( + userID: Mastodon.Entity.Account.ID, + maxID: String?, + authorizationBox: MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = authorizationBox.domain + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + + let query = Mastodon.API.Account.FollowingQuery( + maxID: maxID, + limit: nil + ) + return Mastodon.API.Account.following( + session: session, + domain: domain, + userID: userID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + for entity in response.value { + _ = APIService.CoreData.createOrMergeMastodonUser( + into: managedObjectContext, + for: requestMastodonUser, + in: domain, + entity: entity, + userCache: nil, + networkDate: response.networkDate, + log: .api + ) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift new file mode 100644 index 000000000..c992c7584 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Following.swift @@ -0,0 +1,82 @@ +// +// Mastodon+API+Account+Following.swift +// +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func followingEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") + .appendingPathComponent(userID) + .appendingPathComponent("following") + } + + /// Following + /// + /// Accounts which the given account is following, if network is not hidden by the account owner. + /// + /// - Since: 0.0.0 + /// - Version: 3.4.1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Account]` nested in the response + public static func following( + session: URLSession, + domain: String, + userID: Mastodon.Entity.Account.ID, + query: FollowingQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: followingEndpointURL(domain: domain, userID: userID), + query: query, + 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() + } + + public struct FollowingQuery: Codable, GetQuery { + + public let maxID: String? + public let limit: Int? // default 40 + + enum CodingKeys: String, CodingKey { + case maxID = "max_id" + case limit + } + + public init( + maxID: String?, + limit: Int? + ) { + self.maxID = maxID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + + } + +}