From 5d3b6d1943bf4f7bc290e23ce051f16055323210 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 18:13:45 +0800 Subject: [PATCH] feat: handle profile follow, block, and mute actions --- .../CoreData.xcdatamodel/contents | 10 +- CoreDataStack/Entity/MastodonUser.swift | 23 + Localization/app.json | 19 +- Mastodon.xcodeproj/project.pbxproj | 46 +- Mastodon/Coordinator/SceneCoordinator.swift | 18 + Mastodon/Diffiable/Section/PollSection.swift | 12 + .../CoreDataStack/MastodonUser.swift | 9 +- Mastodon/Generated/Assets.swift | 5 + Mastodon/Generated/Strings.swift | 40 ++ Mastodon/Helper/MastodonMetricFormatter.swift | 41 ++ .../Protocol/AvatarConfigurableView.swift | 28 +- .../Protocol/UserProvider/UserProvider.swift | 16 + .../UserProvider/UserProviderFacade.swift | 204 +++++++++ .../Profile/Banner/Contents.json | 9 + .../username.gray.colorset/Contents.json | 20 + .../Assets.xcassets/Profile/Contents.json | 9 + .../Resources/en.lproj/Localizable.strings | 11 + .../Header/ProfileHeaderViewController.swift | 34 +- .../View/ProfileFriendshipActionButton.swift | 71 --- .../Header/View/ProfileHeaderView.swift | 53 ++- .../ProfileRelationshipActionButton.swift | 40 ++ .../ProfileViewController+UserProvider.swift | 20 + .../Scene/Profile/ProfileViewController.swift | 420 +++++++++--------- Mastodon/Scene/Profile/ProfileViewModel.swift | 251 +++++++++-- .../Service/APIService/APIService+Block.swift | 167 +++++++ .../APIService/APIService+Follow.swift | 187 ++++++++ .../Service/APIService/APIService+Mute.swift | 167 +++++++ .../APIService/APIService+Relationship.swift | 84 ++-- .../APIService+CoreData+MastodonUser.swift | 1 + .../API/Mastodon+API+Account+Friendship.swift | 347 +++++++++++++++ 30 files changed, 1960 insertions(+), 402 deletions(-) create mode 100644 Mastodon/Helper/MastodonMetricFormatter.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProvider.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProviderFacade.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Contents.json delete mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift create mode 100644 Mastodon/Service/APIService/APIService+Block.swift create mode 100644 Mastodon/Service/APIService/APIService+Follow.swift create mode 100644 Mastodon/Service/APIService/APIService+Mute.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index d655753d3..e2f059d50 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -69,6 +69,7 @@ + @@ -78,6 +79,7 @@ + @@ -197,12 +199,12 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 2228787b5..878eb9ad4 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -29,6 +29,9 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var followingCount: NSNumber @NSManaged public private(set) var followersCount: NSNumber + @NSManaged public private(set) var locked: Bool + @NSManaged public private(set) var bot: Bool + @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -88,6 +91,9 @@ extension MastodonUser { user.followingCount = NSNumber(value: property.followingCount) user.followersCount = NSNumber(value: property.followersCount) + user.locked = property.locked + user.bot = property.bot ?? false + // Mastodon do not provide relationship on the `Account` // Update relationship via attribute updating interface @@ -158,6 +164,17 @@ extension MastodonUser { self.followersCount = NSNumber(value: followersCount) } } + public func update(locked: Bool) { + if self.locked != locked { + self.locked = locked + } + } + public func update(bot: Bool) { + if self.bot != bot { + self.bot = bot + } + } + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { if isFollowing { if !(self.followingBy ?? Set()).contains(mastodonUser) { @@ -249,6 +266,8 @@ extension MastodonUser { public let statusesCount: Int public let followingCount: Int public let followersCount: Int + public let locked: Bool + public let bot: Bool? public let createdAt: Date public let networkDate: Date @@ -268,6 +287,8 @@ extension MastodonUser { statusesCount: Int, followingCount: Int, followersCount: Int, + locked: Bool, + bot: Bool?, createdAt: Date, networkDate: Date ) { @@ -286,6 +307,8 @@ extension MastodonUser { self.statusesCount = statusesCount self.followingCount = followingCount self.followersCount = followersCount + self.locked = locked + self.bot = bot self.createdAt = createdAt self.networkDate = networkDate } diff --git a/Localization/app.json b/Localization/app.json index 812a801ac..9eaba58f4 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,9 +69,16 @@ "firendship": { "follow": "Follow", "following": "Following", + "pending": "Pending", "block": "Block", + "block_user": "Block %s", + "unblock": "Unblock", + "unblock_user": "Unblock %s", "blocked": "Blocked", "mute": "Mute", + "mute_user": "Mute %s", + "unmute": "Unmute", + "unmute_user": "Unmute %s", "muted": "Muted", "edit_info": "Edit info" }, @@ -257,6 +264,16 @@ "posts": "Posts", "replies": "Replies", "media": "Media" + }, + "relationship_action_alert": { + "confirm_unmute_user": { + "title": "Unmute Account", + "message": "Confirm unmute %s" + }, + "confirm_unblock_usre": { + "title": "Unblock Account", + "message": "Confirm unblock %s" + } } }, "search": { @@ -266,4 +283,4 @@ } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e86ad664..2d5d0486b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -134,7 +134,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; }; + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; @@ -259,6 +259,13 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */; }; + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -473,7 +480,7 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; @@ -604,6 +611,13 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = ""; }; + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = ""; }; + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -885,6 +899,7 @@ isa = PBXGroup; children = ( 2D38F1FC25CD47D900561493 /* StatusProvider */, + DBAE3F742615DD63004B8251 /* UserProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, @@ -1190,6 +1205,9 @@ 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, ); path = APIService; sourceTree = ""; @@ -1400,8 +1418,8 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, + 5D03938E2612D200007FE196 /* Webview */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, @@ -1503,6 +1521,7 @@ DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, @@ -1537,6 +1556,7 @@ children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, ); path = Helper; sourceTree = ""; @@ -1558,6 +1578,15 @@ path = View; sourceTree = ""; }; + DBAE3F742615DD63004B8251 /* UserProvider */ = { + isa = PBXGroup; + children = ( + DBAE3F672615DD60004B8251 /* UserProvider.swift */, + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, + ); + path = UserProvider; + sourceTree = ""; + }; DBB525132611EBB1002F1F29 /* Segmented */ = { isa = PBXGroup; children = ( @@ -1603,7 +1632,7 @@ DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, ); path = View; @@ -1990,6 +2019,7 @@ DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, @@ -2028,6 +2058,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -2096,6 +2127,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2106,7 +2138,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, @@ -2128,6 +2160,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, @@ -2152,12 +2185,14 @@ DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, @@ -2216,6 +2251,7 @@ DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index d578ee528..4b6eed7ba 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -56,6 +56,7 @@ extension SceneCoordinator { // misc case alertController(alertController: UIAlertController) + case safari(url: URL) #if DEBUG case publicTimeline @@ -111,6 +112,17 @@ extension SceneCoordinator { guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else { return nil } + // adapt for child controller + if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { + switch viewController { + case is ProfileViewController: + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem + default: + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil + } + } if let mainTabBarController = presentingViewController as? MainTabBarController, let navigationController = mainTabBarController.selectedViewController as? UINavigationController, @@ -222,6 +234,12 @@ private extension SceneCoordinator { ) } viewController = alertController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 2f9404410..044f4fb9d 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -9,6 +9,18 @@ import UIKit import CoreData import CoreDataStack +import MastodonSDK + +extension Mastodon.Entity.Attachment: Hashable { + public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + enum PollSection: Equatable, Hashable { case main } diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 471adb815..e140ab95a 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -26,6 +26,8 @@ extension MastodonUser.Property { statusesCount: entity.statusesCount, followingCount: entity.followingCount, followersCount: entity.followersCount, + locked: entity.locked, + bot: entity.bot, createdAt: entity.createdAt, networkDate: networkDate ) @@ -39,7 +41,12 @@ extension MastodonUser { } var acctWithDomain: String { - return username + "@" + domain + if !acct.contains("@") { + // Safe concat due to username cannot contains "@" + return username + "@" + domain + } else { + return acct + } } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8276cfb20..0abcc2341 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -93,6 +93,11 @@ internal enum Asset { internal enum Connectivity { internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") } + internal enum Profile { + internal enum Banner { + internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") + } + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a308033fc..53ef603e2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -92,6 +92,10 @@ internal enum L10n { internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Block %@ + internal static func blockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1)) + } /// Edit info internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") /// Follow @@ -102,6 +106,24 @@ internal enum L10n { internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") /// Muted internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + /// Mute %@ + internal static func muteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.MuteUser", String(describing: p1)) + } + /// Pending + internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Unblock + internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") + /// Unblock %@ + internal static func unblockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnblockUser", String(describing: p1)) + } + /// Unmute + internal static let unmute = L10n.tr("Localizable", "Common.Controls.Firendship.Unmute") + /// Unmute %@ + internal static func unmuteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnmuteUser", String(describing: p1)) + } } internal enum Status { /// Tap to reveal that may be sensitive @@ -290,6 +312,24 @@ internal enum L10n { /// posts internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") } + internal enum RelationshipActionAlert { + internal enum ConfirmUnblockUsre { + /// Confirm unblock %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1)) + } + /// Unblock Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title") + } + internal enum ConfirmUnmuteUser { + /// Confirm unmute %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1)) + } + /// Unmute Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title") + } + } internal enum SegmentedControl { /// Media internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") diff --git a/Mastodon/Helper/MastodonMetricFormatter.swift b/Mastodon/Helper/MastodonMetricFormatter.swift new file mode 100644 index 000000000..0711669fb --- /dev/null +++ b/Mastodon/Helper/MastodonMetricFormatter.swift @@ -0,0 +1,41 @@ +// +// MastodonMetricFormatter.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import Foundation + +final class MastodonMetricFormatter: Formatter { + + func string(from number: Int) -> String? { + let isPositive = number >= 0 + let symbol = isPositive ? "" : "-" + + let numberFormatter = NumberFormatter() + + let value = abs(number) + let metric: String + + switch value { + case 0..<1000: // 0 ~ 1K + metric = String(value) + case 1000..<10000: // 1K ~ 10K + numberFormatter.maximumFractionDigits = 1 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + case 10000..<1000000: // 10K ~ 1M + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + default: + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000) + metric = string + "M" + } + + return symbol + metric + } + +} diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6391066e1..b8c5285a2 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -84,6 +84,8 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarImageView, configuration: configuration) } if let avatarButton = configurableAvatarButton { @@ -110,9 +112,24 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarButton, configuration: configuration) } } + func configureLayerBorder(view: UIView, configuration: AvatarConfigurableViewConfiguration) { + guard let borderWidth = configuration.borderWidth, borderWidth > 0, + let borderColor = configuration.borderColor else { + return + } + + view.layer.masksToBounds = true + view.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = borderColor.cgColor + view.layer.borderWidth = borderWidth + } + func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { } } @@ -121,10 +138,19 @@ struct AvatarConfigurableViewConfiguration { let avatarImageURL: URL? let placeholderImage: UIImage? + let borderColor: UIColor? + let borderWidth: CGFloat? - init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) { + init( + avatarImageURL: URL?, + placeholderImage: UIImage? = nil, + borderColor: UIColor? = nil, + borderWidth: CGFloat? = nil + ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage + self.borderColor = borderColor + self.borderWidth = borderWidth } } diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift new file mode 100644 index 000000000..63a1f8e68 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -0,0 +1,16 @@ +// +// UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { + // async + func mastodonUser() -> Future +} diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift new file mode 100644 index 000000000..04297772b --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -0,0 +1,204 @@ +// +// UserProviderFacade.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +enum UserProviderFacade { } + +extension UserProviderFacade { + + static func toggleUserFollowRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserFollowRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserFollowRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserBlockRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserBlockRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleBlock( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserMuteRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserMuteRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleMute( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func createProfileActionMenu( + for mastodonUser: MastodonUser, + isMuting: Bool, + isBlocking: Bool, + provider: UserProvider + ) -> UIMenu { + var children: [UIMenuElement] = [] + let name = mastodonUser.displayNameWithFallback + + // mute + let muteAction = UIAction( + title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, + image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), + discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), + attributes: isMuting ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserMuteRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isMuting { + children.append(muteAction) + } else { + let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) + children.append(muteMenu) + } + + // block + let blockAction = UIAction( + title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, + image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), + discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), + attributes: isBlocking ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserBlockRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isBlocking { + children.append(blockAction) + } else { + let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) + children.append(blockMenu) + } + + return UIMenu(title: "", options: [], children: children) + } + +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json new file mode 100644 index 000000000..473d42adc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0.961", + "green" : "0.922", + "red" : "0.922" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 88ecd2508..4a9b7bd30 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -29,12 +29,19 @@ Please check your internet connection."; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; "Common.Controls.Firendship.Follow" = "Follow"; "Common.Controls.Firendship.Following" = "Following"; "Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -96,6 +103,10 @@ tap the link to confirm your account."; "Scene.Profile.Dashboard.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 58a7a6110..855581902 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -21,7 +21,7 @@ final class ProfileHeaderViewController: UIViewController { weak var delegate: ProfileHeaderViewControllerDelegate? - let profileBannerView = ProfileHeaderView() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) segmenetedControl.selectedSegmentIndex = 0 @@ -31,7 +31,7 @@ final class ProfileHeaderViewController: UIViewController { private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 - private var isAdjustBannerImageViewForSafeAreaInset = false + // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero deinit { @@ -47,19 +47,19 @@ extension ProfileHeaderViewController { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - profileBannerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(profileBannerView) + profileHeaderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ - profileBannerView.topAnchor.constraint(equalTo: view.topAnchor), - profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), + profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - profileBannerView.preservesSuperviewLayoutMargins = true + profileHeaderView.preservesSuperviewLayoutMargins = true pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false view.addSubview(pageSegmentedControl) NSLayoutConstraint.activate([ - pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), @@ -72,11 +72,13 @@ extension ProfileHeaderViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !isAdjustBannerImageViewForSafeAreaInset { - isAdjustBannerImageViewForSafeAreaInset = true - profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top - profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top - } + // Deprecated: + // not needs this tweak due to force layout update in the parent + // if !isAdjustBannerImageViewForSafeAreaInset { + // isAdjustBannerImageViewForSafeAreaInset = true + // profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + // profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top + // } } override func viewDidLayoutSubviews() { @@ -115,13 +117,13 @@ extension ProfileHeaderViewController { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) updateHeaderBottomShadow(progress: progress) - let bannerImageView = profileBannerView.bannerImageView + let bannerImageView = profileHeaderView.bannerImageView guard bannerImageView.bounds != .zero else { // wait layout finish return } - let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil) + let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift deleted file mode 100644 index 286145ffd..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ProfileFriendshipActionButton.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit - -final class ProfileFriendshipActionButton: RoundedEdgesButton { - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ProfileFriendshipActionButton { - private func _init() { - configure(state: .follow) - } -} - -extension ProfileFriendshipActionButton { - enum State { - case follow - case following - case blocked - case muted - case edit - case editing - - var title: String { - switch self { - case .follow: return L10n.Common.Controls.Firendship.follow - case .following: return L10n.Common.Controls.Firendship.following - case .blocked: return L10n.Common.Controls.Firendship.blocked - case .muted: return L10n.Common.Controls.Firendship.muted - case .edit: return L10n.Common.Controls.Firendship.editInfo - case .editing: return L10n.Common.Controls.Actions.done - } - } - - var backgroundColor: UIColor { - switch self { - case .follow: return Asset.Colors.Button.normal.color - case .following: return Asset.Colors.Button.normal.color - case .blocked: return Asset.Colors.Background.danger.color - case .muted: return Asset.Colors.Background.alertYellow.color - case .edit: return Asset.Colors.Button.normal.color - case .editing: return Asset.Colors.Button.normal.color - } - } - } - - private func configure(state: State) { - setTitle(state.title, for: .normal) - setTitleColor(.white, for: .normal) - setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - } -} - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 7fac52896..a6b1f275c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import ActiveLabel protocol ProfileHeaderViewDelegate: class { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) @@ -22,6 +23,7 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + static let bannerImageViewPlaceholderColor = UIColor.systemGray weak var delegate: ProfileHeaderViewDelegate? @@ -29,10 +31,18 @@ final class ProfileHeaderView: UIView { let bannerImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill - imageView.image = .placeholder(color: .systemGray) + imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) imageView.layer.masksToBounds = true + // #if DEBUG + // imageView.image = .placeholder(color: .red) + // #endif return imageView }() + let bannerImageViewOverlayView: UIView = { + let overlayView = UIView() + overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + return overlayView + }() let avatarImageView: UIImageView = { let imageView = UIImageView() @@ -59,14 +69,18 @@ final class ProfileHeaderView: UIView { label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 - label.textColor = .white + label.textColor = Asset.Profile.Banner.usernameGray.color label.text = "@alice" label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return label }() let statusDashboardView = ProfileStatusDashboardView() - let friendshipActionButton = ProfileFriendshipActionButton() + let relationshipActionButton: ProfileRelationshipActionButton = { + let button = ProfileRelationshipActionButton() + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + return button + }() let bioContainerView = UIView() let fieldContainerStackView = UIStackView() @@ -103,6 +117,15 @@ extension ProfileHeaderView { bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] bannerImageView.frame = bannerContainerView.bounds bannerContainerView.addSubview(bannerImageView) + + bannerImageViewOverlayView.translatesAutoresizingMaskIntoConstraints = false + bannerImageView.addSubview(bannerImageViewOverlayView) + NSLayoutConstraint.activate([ + bannerImageViewOverlayView.topAnchor.constraint(equalTo: bannerImageView.topAnchor), + bannerImageViewOverlayView.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor), + bannerImageViewOverlayView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), + bannerImageViewOverlayView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), + ]) // avatar avatarImageView.translatesAutoresizingMaskIntoConstraints = false @@ -156,14 +179,14 @@ extension ProfileHeaderView { statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), ]) - friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(friendshipActionButton) + relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(relationshipActionButton) NSLayoutConstraint.activate([ - friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), - friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), - friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), - friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), - friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), ]) bioContainerView.preservesSuperviewLayoutMargins = true @@ -184,10 +207,20 @@ extension ProfileHeaderView { bringSubviewToFront(nameContainerStackView) bioActiveLabel.delegate = self + + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) } } +extension ProfileHeaderView { + @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + assert(sender === relationshipActionButton) + delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) + } +} + // MARK: - ActiveLabelDelegate extension ProfileHeaderView: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift new file mode 100644 index 000000000..b098c1ec1 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -0,0 +1,40 @@ +// +// ProfileRelationshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileRelationshipActionButton: RoundedEdgesButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileRelationshipActionButton { + private func _init() { + // do nothing + } +} + +extension ProfileRelationshipActionButton { + func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) { + setTitle(actionOptionSet.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } +} + diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift new file mode 100644 index 000000000..3a26db1c1 --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -0,0 +1,20 @@ +// +// ProfileViewController+UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine +import CoreDataStack + +extension ProfileViewController: UserProvider { + + func mastodonUser() -> Future { + return Future { promise in + promise(.success(self.viewModel.mastodonUser.value)) + } + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 4b74ad632..57a398b4b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,11 +18,17 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! - private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + let moreMenuBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + return barButtonItem + }() let refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() @@ -78,7 +84,7 @@ extension ProfileViewController { height: bottomPageHeight + headerViewHeight ) self.overlayScrollView.contentSize = contentSize - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) } } @@ -86,7 +92,7 @@ extension ProfileViewController { extension ProfileViewController { override var preferredStatusBarStyle: UIStatusBarStyle { - return preferredStatusBarStyleForBanner + return .lightContent } override func viewSafeAreaInsetsDidChange() { @@ -95,25 +101,45 @@ extension ProfileViewController { profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) } + override var isViewLoaded: Bool { + return super.isViewLoaded + } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + let barAppearance = UINavigationBarAppearance() barAppearance.configureWithTransparentBackground() navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance + navigationItem.titleView = UIView() -// if navigationController?.viewControllers.first == self { -// navigationItem.leftBarButtonItem = avatarBarButtonItem -// } -// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) - -// unmuteMenuBarButtonItem.target = self -// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:)) + Publishers.CombineLatest( + viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), + viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + guard let self = self else { return } + var items: [UIBarButtonItem] = [] + if !isReplyBarButtonItemHidden { + items.append(self.replyBarButtonItem) + } + if !isMoreMenuBarButtonItemHidden { + items.append(self.moreMenuBarButtonItem) + } + guard !items.isEmpty else { + self.navigationItem.rightBarButtonItems = nil + return + } + self.navigationItem.rightBarButtonItems = items + } + .store(in: &disposeBag) + // Publishers.CombineLatest4( // viewModel.muted.eraseToAnyPublisher(), @@ -244,23 +270,15 @@ extension ProfileViewController { profileHeaderViewController.delegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self -// // add segmented bar to header -// profileSegmentedViewController.pagingViewController.addBar( -// bar, -// dataSource: profileSegmentedViewController.pagingViewController.viewModel, -// at: .custom(view: profileHeaderViewController.view, layout: { bar in -// bar.translatesAutoresizingMaskIntoConstraints = false -// self.profileHeaderViewController.view.addSubview(bar) -// NSLayoutConstraint.activate([ -// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), -// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), -// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), -// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh), -// ]) -// }) -// ) - // bind view model + viewModel.name + .receive(on: DispatchQueue.main) + .sink { [weak self] name in + guard let self = self else { return } + self.title = name + } + .store(in: &disposeBag) + Publishers.CombineLatest( viewModel.bannerImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -268,56 +286,29 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] bannerImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color) + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage( + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( withURL: bannerImageURL, placeholderImage: placeholder, imageTransition: .crossDissolve(0.3), runImageTransitionIfCached: false, completion: { [weak self] response in guard let self = self else { return } - switch response.result { - case .success(let image): - self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark - case .failure: - break + guard let image = response.value else { return } + guard image.size.width > 1 && image.size.height > 1 else { + // restore to placeholder when image invalid + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder + return } } ) } .store(in: &disposeBag) - viewModel.headerDomainLumaStyle - .receive(on: DispatchQueue.main) - .sink { [weak self] style in - guard let self = self else { return } - let textColor: UIColor - let shadowColor: UIColor - switch style { - case .light: - self.preferredStatusBarStyleForBanner = .darkContent - textColor = .black - shadowColor = .white - case .dark: - self.preferredStatusBarStyleForBanner = .lightContent - textColor = .white - shadowColor = .black - default: - self.preferredStatusBarStyleForBanner = .default - textColor = .white - shadowColor = .black - } - - self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - } - .store(in: &disposeBag) Publishers.CombineLatest( viewModel.avatarImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -325,147 +316,100 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] avatarImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.configure( - with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL) + self.profileHeaderViewController.profileHeaderView.configure( + with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2) ) } .store(in: &disposeBag) -// viewModel.protected -// .map { $0 != true } -// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView) -// .store(in: &disposeBag) viewModel.name .map { $0 ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) .store(in: &disposeBag) viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) .store(in: &disposeBag) -// viewModel.friendship -// .sink { [weak self] friendship in -// guard let self = self else { return } -// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton -// followingButton.isHidden = friendship == nil -// -// if let friendship = friendship { -// switch friendship { -// case .following: followingButton.style = .following -// case .pending: followingButton.style = .pending -// case .none: followingButton.style = .follow -// } -// } -// } -// .store(in: &disposeBag) -// viewModel.followedBy -// .sink { [weak self] followedBy in -// guard let self = self else { return } -// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel -// followStatusLabel.isHidden = followedBy != true -// } -// .store(in: &disposeBag) -// + viewModel.relationshipActionOptionSet + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionOptionSet in + guard let self = self else { return } + guard let mastodonUser = self.viewModel.mastodonUser.value else { + self.moreMenuBarButtonItem.menu = nil + return + } + let isMuting = relationshipActionOptionSet.contains(.muting) + let isBlocking = relationshipActionOptionSet.contains(.blocking) + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self) + } + .store(in: &disposeBag) + viewModel.isRelationshipActionButtonHidden + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + guard let self = self else { return } + self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden + } + .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), + viewModel.isEditing.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionSet, isEditing in + guard let self = self else { return } + let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton + if relationshipActionSet.contains(.edit) { + friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit) + } else { + friendshipButton.configure(actionOptionSet: relationshipActionSet) + } + } + .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] bio in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "") + self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "") }) .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.url.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] url, isSuspended in -// guard let self = self else { return } -// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal) -// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.location.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] location, isSuspended in -// guard let self = self else { return } -// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal) -// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) viewModel.statusesCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followingCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followersCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) -// viewModel.followersCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.listedCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.suspended -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isSuspended in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended -// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended -// if isSuspended { -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileTweetPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileMediaPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserMediaTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileLikesPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserLikeTimelineViewModel.State.Suspended.self) -// } -// } -// .store(in: &disposeBag) - -// - profileHeaderViewController.profileBannerView.delegate = self + + profileHeaderViewController.profileHeaderView.delegate = self } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // set back button tint color in SceneCoordinator.present(scene:from:transition:) + + // force layout to make banner image tweak take effect + view.layoutIfNeeded() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + viewModel.viewDidAppear.send() // set overlay scroll view initial content size @@ -483,6 +427,11 @@ extension ProfileViewController { extension ProfileViewController { + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + // TODO: + } + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -600,62 +549,97 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // setup observer and gesture fallback currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - - -// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController, -// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserMediaTimelineViewModel.State.NoMore, -// is UserMediaTimelineViewModel.State.NotAuthorized, -// is UserMediaTimelineViewModel.State.Blocked: -// break -// default: -// if userMediaTimelineViewController.viewModel.items.value.isEmpty { -// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self) -// } -// } -// } -// -// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController, -// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserLikeTimelineViewModel.State.NoMore, -// is UserLikeTimelineViewModel.State.NotAuthorized, -// is UserLikeTimelineViewModel.State.Blocked: -// break -// default: -// if userLikeTimelineViewController.viewModel.items.value.isEmpty { -// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self) -// } -// } -// } } } -// MARK: - ProfileBannerInfoActionViewDelegate -//extension ProfileViewController: ProfileBannerInfoActionViewDelegate { -// -// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) { -// UserProviderFacade -// .toggleUserFriendship(provider: self, sender: button) -// .sink { _ in -// // do nothing -// } receiveValue: { _ in -// // do nothing -// } -// .store(in: &disposeBag) -// } -// -//} - // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { + let relationshipActionSet = viewModel.relationshipActionOptionSet.value + if relationshipActionSet.contains(.edit) { + viewModel.isEditing.value.toggle() + } else { + guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } + switch relationshipAction { + case .none: + break + case .follow, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + + } receiveValue: { _ in + + } + .store(in: &disposeBag) + case .pending: + break + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), + preferredStyle: .alert + ) + let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserMuteRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unmuteAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocking: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), + preferredStyle: .alert + ) + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserBlockRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocked: + break + default: + assertionFailure() + } + + } } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + switch entity.type { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + // TODO: + break + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index f7248009d..5df8952b8 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -26,14 +26,12 @@ class ProfileViewModel: NSObject { let mastodonUser: CurrentValueSubject let currentMastodonUser = CurrentValueSubject(nil) let viewDidAppear = PassthroughSubject() - let headerDomainLumaStyle = CurrentValueSubject(.dark) // default dark for placeholder banner // output let domain: CurrentValueSubject let userID: CurrentValueSubject let bannerImageURL: CurrentValueSubject let avatarImageURL: CurrentValueSubject -// let protected: CurrentValueSubject let name: CurrentValueSubject let username: CurrentValueSubject let bioDescription: CurrentValueSubject @@ -42,13 +40,19 @@ class ProfileViewModel: NSObject { let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject -// let friendship: CurrentValueSubject -// let followedBy: CurrentValueSubject -// let muted: CurrentValueSubject -// let blocked: CurrentValueSubject -// -// let suspended = CurrentValueSubject(false) -// + let protected: CurrentValueSubject + // let suspended: CurrentValueSubject + + let relationshipActionOptionSet = CurrentValueSubject(.none) + let isEditing = CurrentValueSubject(false) + let isFollowedBy = CurrentValueSubject(false) + let isMuting = CurrentValueSubject(false) + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) + + let isRelationshipActionButtonHidden = CurrentValueSubject(true) + let isReplyBarButtonItemHidden = CurrentValueSubject(true) + let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context @@ -65,11 +69,14 @@ class ProfileViewModel: NSObject { self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) -// self.friendship = CurrentValueSubject(nil) -// self.followedBy = CurrentValueSubject(nil) -// self.muted = CurrentValueSubject(false) -// self.blocked = CurrentValueSubject(false) + self.protected = CurrentValueSubject(mastodonUser?.locked) super.init() + + relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: \.value, on: isRelationshipActionButtonHidden) + .store(in: &disposeBag) // bind active authentication context.authenticationService.activeMastodonAuthentication @@ -84,26 +91,54 @@ class ProfileViewModel: NSObject { self.currentMastodonUser.value = activeMastodonAuthentication.user } .store(in: &disposeBag) - - setup() - } - -} - -extension ProfileViewModel { - - enum Friendship: CustomDebugStringConvertible { - case following - case pending - case none - var debugDescription: String { - switch self { - case .following: return "following" - case .pending: return "pending" - case .none: return "none" + // query relationship + let mastodonUserID = self.mastodonUser.map { $0?.id } + let pendingRetryPublisher = CurrentValueSubject(1) + + Publishers.CombineLatest3( + mastodonUserID.removeDuplicates().eraseToAnyPublisher(), + context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(), + pendingRetryPublisher.eraseToAnyPublisher() + ) + .compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.MastodonAuthenticationBox)? in + guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil } + guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil } + return (mastodonUserID, activeMastodonAuthenticationBox) + } + .setFailureType(to: Error.self) // allow failure + .flatMap { mastodonUserID, activeMastodonAuthenticationBox -> AnyPublisher, Error> in + let domain = activeMastodonAuthenticationBox.domain + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch for user %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUserID) + + return self.context.apiService.relationship(domain: domain, accountIDs: [mastodonUserID], authorizationBox: activeMastodonAuthenticationBox) + //.retry(3) + .eraseToAnyPublisher() + } + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update success", ((#file as NSString).lastPathComponent), #line, #function) + + // there are seconds delay after request follow before requested -> following. Query again when needs + guard let relationship = response.value.first else { return } + if relationship.requested == true { + let delay = pendingRetryPublisher.value + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let _ = self else { return } + pendingRetryPublisher.value = min(2 * delay, 60) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) + } } } + .store(in: &disposeBag) + + setup() } } @@ -117,9 +152,11 @@ extension ProfileViewModel { .receive(on: DispatchQueue.main) .sink { [weak self] mastodonUser, currentMastodonUser in guard let self = self else { return } + // Update view model attribute self.update(mastodonUser: mastodonUser) self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + // Setup observer for user if let mastodonUser = mastodonUser { // setup observer self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) @@ -147,6 +184,7 @@ extension ProfileViewModel { self.mastodonUserObserver = nil } + // Setup observer for user if let currentMastodonUser = currentMastodonUser { // setup observer self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) @@ -179,7 +217,6 @@ extension ProfileViewModel { self.userID.value = mastodonUser?.id self.bannerImageURL.value = mastodonUser?.headerImageURL() self.avatarImageURL.value = mastodonUser?.avatarImageURL() -// self.protected.value = twitterUser?.protected self.name.value = mastodonUser?.displayNameWithFallback self.username.value = mastodonUser?.acctWithDomain self.bioDescription.value = mastodonUser?.note @@ -187,11 +224,159 @@ extension ProfileViewModel { self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + self.protected.value = mastodonUser?.locked } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { - // TODO: + guard let mastodonUser = mastodonUser, + let currentMastodonUser = currentMastodonUser else { + // set relationship + self.relationshipActionOptionSet.value = .none + self.isFollowedBy.value = false + self.isMuting.value = false + self.isBlocking.value = false + self.isBlockedBy.value = false + + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + return + } + + if mastodonUser == currentMastodonUser { + self.relationshipActionOptionSet.value = [.edit] + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + } else { + // set with follow action default + var relationshipActionSet = RelationshipActionOptionSet([.follow]) + + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isFollowing { + relationshipActionSet.insert(.following) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) + + let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isPending { + relationshipActionSet.insert(.pending) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) + + let isFollowedBy = currentMastodonUser.followingBy.flatMap { $0.contains(mastodonUser) } ?? false + self.isFollowedBy.value = isFollowedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) + + let isMuting = mastodonUser.mutingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isMuting { + relationshipActionSet.insert(.muting) + } + self.isMuting.value = isMuting + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) + + let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlocking { + relationshipActionSet.insert(.blocking) + } + self.isBlocking.value = isBlocking + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) + + let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + self.isBlockedBy.value = isBlockedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) + + self.relationshipActionOptionSet.value = relationshipActionSet + + // set bar button item state + self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy + self.isMoreMenuBarButtonItemHidden.value = false + } } - +} + +extension ProfileViewModel { + + enum RelationshipAction: Int, CaseIterable { + case none // set hide from UI + case follow + case pending + case following + case muting + case blocking + case blocked + case edit + case editing + + var option: RelationshipActionOptionSet { + return RelationshipActionOptionSet(rawValue: 1 << rawValue) + } + } + + // construct option set on the enum for safe iterator + struct RelationshipActionOptionSet: OptionSet { + let rawValue: Int + + static let none = RelationshipAction.none.option + static let follow = RelationshipAction.follow.option + static let pending = RelationshipAction.pending.option + static let following = RelationshipAction.following.option + static let muting = RelationshipAction.muting.option + static let blocking = RelationshipAction.blocking.option + static let blocked = RelationshipAction.blocked.option + static let edit = RelationshipAction.edit.option + static let editing = RelationshipAction.editing.option + + static let editOptions: RelationshipActionOptionSet = [.edit, .editing] + + func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { + let set = subtracting(except) + for action in RelationshipAction.allCases.reversed() where set.contains(action.option) { + return action + } + + return nil + } + + var title: String { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return " " + } + switch highPriorityAction { + case .none: return " " + case .follow: return L10n.Common.Controls.Firendship.follow + case .pending: return L10n.Common.Controls.Firendship.pending + case .following: return L10n.Common.Controls.Firendship.following + case .muting: return L10n.Common.Controls.Firendship.muted + case .blocking: return L10n.Common.Controls.Firendship.blocked + case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + } + } + + var backgroundColor: UIColor { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return Asset.Colors.Button.normal.color + } + switch highPriorityAction { + case .none: return Asset.Colors.Button.normal.color + case .follow: return Asset.Colors.Button.normal.color + case .pending: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .muting: return Asset.Colors.Background.alertYellow.color + case .blocking: return Asset.Colors.Background.danger.color + case .blocked: return Asset.Colors.Button.disabled.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + } + } + + } } diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift new file mode 100644 index 000000000..ccd17c612 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -0,0 +1,167 @@ +// +// APIService+Block.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleBlock( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { blockQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.blockUpdateRemote( + blockQueryType: blockQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return block query update type for remote request + func blockUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.BlockQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isBlocking = (mastodonUser.blockingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isBlocking ? .unblock : .block + mastodonUser.update(isBlocking: !isBlocking, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func blockUpdateRemote( + blockQueryType: Mastodon.API.Account.BlockQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.block( + session: session, + domain: domain, + accountID: mastodonUserID, + blockQueryType: blockQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch blockQueryType { + case .block: + break + case .unblock: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift new file mode 100644 index 000000000..f52aae999 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -0,0 +1,187 @@ +// +// APIService+Follow.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + /// Toggle friendship between target MastodonUser and current MastodonUser + /// + /// Following / Following pending <-> Unfollow + /// + /// - Parameters: + /// - mastodonUser: target MastodonUser + /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` + /// - Returns: publisher for `Relationship` + func toggleFollow( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { followQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.followUpdateRemote( + followQueryType: followQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return follow query update type for remote request + func followUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.FollowQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isPending = (mastodonUser.followRequestedBy ?? Set()).contains(_requestMastodonUser) + let isFollowing = (mastodonUser.followingBy ?? Set()).contains(_requestMastodonUser) + + if isFollowing || isPending { + _queryType = .unfollow + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } else { + _queryType = .follow(query: Mastodon.API.Account.FollowQuery()) + if mastodonUser.locked { + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser) + } else { + mastodonUser.update(isFollowing: true, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } + } + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func followUpdateRemote( + followQueryType: Mastodon.API.Account.FollowQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.follow( + session: session, + domain: domain, + accountID: mastodonUserID, + followQueryType: followQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + switch followQueryType { + case .follow: + break + case .unfollow: + break + } + } + }) + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift new file mode 100644 index 000000000..2f9303261 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -0,0 +1,167 @@ +// +// APIService+Mute.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleMute( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { muteQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.muteUpdateRemote( + muteQueryType: muteQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return mute query update type for remote request + func muteUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.MuteQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isMuting = (mastodonUser.mutingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isMuting ? .unmute : .mute + mastodonUser.update(isMuting: !isMuting, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func muteUpdateRemote( + muteQueryType: Mastodon.API.Account.MuteQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.mute( + session: session, + domain: domain, + accountID: mastodonUserID, + muteQueryType: muteQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch muteQueryType { + case .mute: + break + case .unmute: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 7ad5b4745..b0ef29267 100644 --- a/Mastodon/Service/APIService/APIService+Relationship.swift +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -5,7 +5,7 @@ // Created by MainasuK Cirno on 2021-4-1. // -import Foundation +import UIKit import Combine import CoreData import CoreDataStack @@ -19,47 +19,47 @@ extension APIService { accountIDs: [Mastodon.Entity.Account.ID], authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { - fatalError() -// let authorization = authorizationBox.userAuthorization -// let requestMastodonUserID = authorizationBox.userID -// let query = Mastodon.API.Account.AccountStatuseseQuery( -// maxID: maxID, -// sinceID: sinceID, -// excludeReplies: excludeReplies, -// excludeReblogs: excludeReblogs, -// onlyMedia: onlyMedia, -// limit: limit -// ) -// -// return Mastodon.API.Account.statuses( -// session: session, -// domain: domain, -// accountID: accountID, -// query: query, -// authorization: authorization -// ) -// .flatMap { response -> AnyPublisher, Error> in -// return APIService.Persist.persistStatus( -// managedObjectContext: self.backgroundManagedObjectContext, -// domain: domain, -// query: nil, -// response: response, -// persistType: .user, -// requestMastodonUserID: requestMastodonUserID, -// log: OSLog.api -// ) -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.RelationshipQuery( + ids: accountIDs + ) + + return Mastodon.API.Account.relationships( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, ids: accountIDs) + lookUpMastodonUserRequest.fetchLimit = accountIDs.count + let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest) + + for user in lookUpMastodonusers { + guard let entity = response.value.first(where: { $0.id == user.id }) else { continue } + APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 745f47999..fdac2a2a6 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -111,6 +111,7 @@ extension APIService.CoreData { networkDate: Date ) { guard networkDate > user.updatedAt else { return } + guard entity.id != requestMastodonUser.id else { return } // not update relationship for self user.update(isFollowing: entity.following, by: requestMastodonUser) entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index ec8bf9d5e..2c0c39b97 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -67,3 +67,350 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + public enum FollowQueryType { + case follow(query: FollowQuery) + case unfollow + } + + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + followQueryType: FollowQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch followQueryType { + case .follow(let query): + return follow(session: session, domain: domain, accountID: accountID, query: query, authorization: authorization) + case .unfollow: + return unfollow(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func followEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/follow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Follow + /// + /// Follow the given account. Can also be used to update whether to show reblogs or enable notifications. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: FollowQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: followEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowQuery: Codable, PostQuery { + public let reblogs: Bool? + public let notify: Bool? + + public init(reblogs: Bool? = nil , notify: Bool? = nil) { + self.reblogs = reblogs + self.notify = notify + } + } + +} + +extension Mastodon.API.Account { + + static func unfollowEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unfollow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unfollow + /// + /// Unfollow the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unfollow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unfollowEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum BlockQueryType { + case block + case unblock + } + + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + blockQueryType: BlockQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch blockQueryType { + case .block: + return block(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unblock: + return unblock(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/block" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Block + /// + /// Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: blockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unblockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unblock" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unblock + /// + /// Unblock the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unblock( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unblockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum MuteQueryType { + case mute + case unmute + } + + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + muteQueryType: MuteQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch muteQueryType { + case .mute: + return mute(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unmute: + return unmute(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func mutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/mute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Mute + /// + /// Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: mutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unmutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unmute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unmute + /// + /// Unmute the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unmute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unmutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +}