diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index f635a3db0..5ed4021a7 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -82,6 +82,7 @@ + @@ -216,4 +217,4 @@ - \ No newline at end of file + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 878eb9ad4..714b6d0f6 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -31,6 +31,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var locked: Bool @NSManaged public private(set) var bot: Bool + @NSManaged public private(set) var suspended: Bool @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -93,6 +94,7 @@ extension MastodonUser { user.locked = property.locked user.bot = property.bot ?? false + user.suspended = property.suspended ?? false // Mastodon do not provide relationship on the `Account` // Update relationship via attribute updating interface @@ -174,6 +176,11 @@ extension MastodonUser { self.bot = bot } } + public func update(suspended: Bool) { + if self.suspended != suspended { + self.suspended = suspended + } + } public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { if isFollowing { @@ -268,6 +275,7 @@ extension MastodonUser { public let followersCount: Int public let locked: Bool public let bot: Bool? + public let suspended: Bool? public let createdAt: Date public let networkDate: Date @@ -289,6 +297,7 @@ extension MastodonUser { followersCount: Int, locked: Bool, bot: Bool?, + suspended: Bool?, createdAt: Date, networkDate: Date ) { @@ -309,6 +318,7 @@ extension MastodonUser { self.followersCount = followersCount self.locked = locked self.bot = bot + self.suspended = suspended self.createdAt = createdAt self.networkDate = networkDate } diff --git a/Localization/app.json b/Localization/app.json index 34496df0f..120458f74 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -44,6 +44,8 @@ "sign_up": "Sign Up", "see_more": "See More", "preview": "Preview", + "share": "Share", + "share_user": "Share %s", "open_in_safari": "Open in Safari" }, "status": { @@ -69,9 +71,11 @@ "firendship": { "follow": "Follow", "following": "Following", + "request": "Request", "pending": "Pending", "block": "Block", "block_user": "Block %s", + "block_domain": "Block %s", "unblock": "Unblock", "unblock_user": "Unblock %s", "blocked": "Blocked", @@ -91,7 +95,8 @@ "no_status_found": "No Status Found", "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", - "suspended_warning": "This account is suspended." + "suspended_warning": "This account has been suspended.", + "user_suspended_warning": "%s's account has been suspended." } } }, @@ -217,7 +222,7 @@ "new_posts": "See new posts", "published": "Published!", "Publishing": "Publishing post..." - }, + } }, "public_timeline": { "title": "Public" @@ -261,6 +266,7 @@ } }, "profile": { + "subtitle": "%s posts", "dashboard": { "posts": "posts", "following": "following", @@ -288,17 +294,17 @@ "cancel": "Cancel" }, "recommend": { - "button_text": "See All", - "hash_tag": { - "title": "Trending in your timeline", - "description": "Hashtags that are getting quite a bit of attention among people you follow", - "people_talking": "%s people are talking" - }, - "accounts": { - "title": "Accounts you might like", - "description": "Except for Sam, you will not like his account.", - "follow": "Follow" - } + "button_text": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "Except for Sam, you will not like his account.", + "follow": "Follow" + } }, "searching": { "segment": { @@ -312,6 +318,9 @@ }, "hashtag": { "prompt": "%s people talking" + }, + "favorite": { + "title": "Your Favorites" } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index fda5de623..b52139898 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -228,10 +228,12 @@ DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; @@ -311,7 +313,6 @@ DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; - DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; }; DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; }; DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; @@ -322,6 +323,12 @@ DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; + DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; }; + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; }; + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; }; + DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; + DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -380,7 +387,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -607,10 +614,12 @@ DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; + DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -691,7 +700,6 @@ DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; - DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; }; DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; }; DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; }; @@ -702,6 +710,12 @@ DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; }; + DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; + DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = ""; }; + DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; + DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; + DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -763,23 +777,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0F1E2D102615C39800C38565 /* View */ = { - isa = PBXGroup; - children = ( - 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */, - ); - path = View; - sourceTree = ""; - }; 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { isa = PBXGroup; children = ( - 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, - 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, ); @@ -859,6 +864,7 @@ 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, ); path = Content; sourceTree = ""; @@ -989,6 +995,7 @@ 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, + DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */, ); path = Protocol; sourceTree = ""; @@ -1166,8 +1173,8 @@ DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( - DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB084B5625CBC56C00F898ED /* Status.swift */, + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, ); path = CoreDataStack; @@ -1248,7 +1255,6 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, - DBCC3B7A261443AD0045B23D /* ViewController.swift */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -1257,6 +1263,7 @@ DB9E0D6925EDFFE500CFDD76 /* Helper */, DB8AF56225C138BC002E6C99 /* Extension */, 2D5A3D0125CF8640002347D6 /* Vender */, + DB73B495261F030D002E9E9F /* Activity */, DB5086CB25CC0DB400C2C187 /* Preference */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */, DB98338425C945ED00AD9700 /* Generated */, @@ -1397,6 +1404,14 @@ path = ServerRules; sourceTree = ""; }; + DB73B495261F030D002E9E9F /* Activity */ = { + isa = PBXGroup; + children = ( + DB73B48F261F030A002E9E9F /* SafariActivity.swift */, + ); + path = Activity; + sourceTree = ""; + }; DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( @@ -1528,13 +1543,13 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 0F2021F5261325ED000C64BF /* HashtagTimeline */, 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, @@ -1637,6 +1652,7 @@ DBB525132611EBB1002F1F29 /* Segmented */, DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, + DBE3CDF1261C6B3100430CC6 /* Favorite */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, @@ -1740,6 +1756,7 @@ children = ( DBB525732612D5A5002F1F29 /* View */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, ); path = Header; sourceTree = ""; @@ -1774,6 +1791,18 @@ path = Register; sourceTree = ""; }; + DBE3CDF1261C6B3100430CC6 /* Favorite */ = { + isa = PBXGroup; + children = ( + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */, + DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */, + DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */, + DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */, + DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */, + ); + path = Favorite; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -2173,7 +2202,7 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, - 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, @@ -2195,6 +2224,7 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, + DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, @@ -2211,6 +2241,7 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, + DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2220,21 +2251,25 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, + DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, @@ -2253,12 +2288,12 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, @@ -2396,6 +2431,7 @@ DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index c1f4dcf1f..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 12 + 10 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Activity/SafariActivity.swift b/Mastodon/Activity/SafariActivity.swift new file mode 100644 index 000000000..e10a0b082 --- /dev/null +++ b/Mastodon/Activity/SafariActivity.swift @@ -0,0 +1,62 @@ +// +// SafariActivity.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-8. +// + +import UIKit +import SafariServices + +final class SafariActivity: UIActivity { + + weak var sceneCoordinator: SceneCoordinator? + var url: NSURL? + + init(sceneCoordinator: SceneCoordinator) { + self.sceneCoordinator = sceneCoordinator + } + + override var activityType: UIActivity.ActivityType? { + return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity") + } + + override var activityTitle: String? { + return L10n.Common.Controls.Actions.openInSafari + } + + override var activityImage: UIImage? { + return UIImage(systemName: "safari") + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + for item in activityItems { + guard let _ = item as? NSURL, sceneCoordinator != nil else { continue } + return true + } + + return false + } + + override func prepare(withActivityItems activityItems: [Any]) { + for item in activityItems { + guard let url = item as? NSURL else { continue } + self.url = url + } + } + + override var activityViewController: UIViewController? { + return nil + } + + override func perform() { + guard let url = url else { + activityDidFinish(false) + return + } + + sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil)) + activityDidFinish(true) + } + +} diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c76c8ba47..36d42745e 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -33,8 +33,8 @@ extension SceneCoordinator { case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case customPush case safariPresent(animated: Bool, completion: (() -> Void)? = nil) - case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) case alertController(animated: Bool, completion: (() -> Void)? = nil) + case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) } enum Scene { @@ -56,10 +56,12 @@ extension SceneCoordinator { // profile case profile(viewModel: ProfileViewModel) + case favorite(viewModel: FavoriteViewModel) // misc - case alertController(alertController: UIAlertController) case safari(url: URL) + case alertController(alertController: UIAlertController) + case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) #if DEBUG case publicTimeline @@ -169,11 +171,11 @@ extension SceneCoordinator { viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) - case .activityViewControllerPresent(let animated, let completion): + case .alertController(let animated, let completion): viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) - case .alertController(let animated, let completion): + case .activityViewControllerPresent(let animated, let completion): viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) } @@ -232,6 +234,16 @@ private extension SceneCoordinator { let _viewController = ProfileViewController() _viewController.viewModel = viewModel viewController = _viewController + case .favorite(let viewModel): + let _viewController = FavoriteViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( @@ -241,12 +253,10 @@ 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) + case .activityViewController(let activityViewController, let sourceView, let barButtonItem): + activityViewController.popoverPresentationController?.sourceView = sourceView + activityViewController.popoverPresentationController?.barButtonItem = barButtonItem + viewController = activityViewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 0a27f1871..9f82f6ca5 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -63,11 +63,21 @@ extension Item { let id = UUID() let reason: Reason - enum Reason { + enum Reason: Equatable { case noStatusFound case blocking case blocked - case suspended + case suspended(name: String?) + + static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool { + switch (lhs, rhs) { + case (.noStatusFound, noStatusFound): return true + case (.blocking, blocking): return true + case (.blocked, blocked): return true + case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight + default: return false + } + } } init(reason: Reason) { diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 52443a13d..456d193f3 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -16,7 +16,8 @@ extension CategoryPickerSection { for collectionView: UICollectionView, dependency: NeedsDependency ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell switch item { case .all: diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift index 2167d6f5c..06b626d0e 100644 --- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -17,7 +17,8 @@ extension CustomEmojiPickerSection { for collectionView: UICollectionView, dependency: NeedsDependency ) -> UICollectionViewDiffableDataSource { - let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } switch item { case .emoji(let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 083e9b813..f5b1ee500 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -25,7 +25,13 @@ extension PickServerSection { pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak pickServerCategoriesCellDelegate, + weak pickServerSearchCellDelegate, + weak pickServerCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return nil } switch item { case .header: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index e140ab95a..bb99b15d4 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -28,6 +28,7 @@ extension MastodonUser.Property { followersCount: entity.followersCount, locked: entity.locked, bot: entity.bot, + suspended: entity.suspended, createdAt: entity.createdAt, networkDate: networkDate ) @@ -62,3 +63,21 @@ extension MastodonUser { } } + +extension MastodonUser { + + var profileURL: URL { + if let urlString = self.url, + let url = URL(string: urlString) { + return url + } else { + return URL(string: "https://\(self.domain)/@\(username)")! + } + } + + var activityItems: [Any] { + var items: [Any] = [] + items.append(profileURL) + return items + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index b6cdde9c5..843fce02c 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -93,6 +93,8 @@ internal enum Asset { } internal enum Profile { internal enum Banner { + internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray") + internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray") internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 864502379..14b993881 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -78,6 +78,12 @@ internal enum L10n { internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") /// See More internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") + /// Share + internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") + /// Share %@ + internal static func shareUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1)) + } /// Sign In internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") /// Sign Up @@ -90,6 +96,10 @@ internal enum L10n { internal enum Firendship { /// Block internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") + /// Block %@ + internal static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) + } /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") /// Block %@ @@ -112,6 +122,8 @@ internal enum L10n { } /// Pending internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Request + internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request") /// Unblock internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") /// Unblock %@ @@ -179,8 +191,12 @@ internal enum L10n { internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") /// No Status Found internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") - /// This account is suspended. + /// This account has been suspended. internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + /// %@'s account has been suspended. + internal static func userSuspendedWarning(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1)) + } } internal enum Loader { /// Loading missing posts... @@ -299,6 +315,10 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") } } + internal enum Favorite { + /// Your Favorites + internal static let title = L10n.tr("Localizable", "Scene.Favorite.Title") + } internal enum Hashtag { /// %@ people talking internal static func prompt(_ p1: Any) -> String { @@ -320,6 +340,10 @@ internal enum L10n { } } internal enum Profile { + /// %@ posts + internal static func subtitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1)) + } internal enum Dashboard { /// followers internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index b8c5285a2..1c2c78da3 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -26,7 +26,7 @@ extension AvatarConfigurableView { if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { return placeholderImage .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) - .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) + .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true) } else { return placeholderImage.af.imageRoundedIntoCircle() } @@ -50,11 +50,20 @@ extension AvatarConfigurableView { defer { avatarConfigurableView(self, didFinishConfiguration: configuration) } - + + let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) + // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { configurableAvatarImageView?.image = placeholderImage + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + configurableAvatarButton?.setImage(placeholderImage, for: .normal) + configurableAvatarButton?.layer.masksToBounds = true + configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular return } @@ -74,7 +83,6 @@ extension AvatarConfigurableView { avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( withURL: avatarImageURL, placeholderImage: placeholderImage, @@ -103,7 +111,6 @@ extension AvatarConfigurableView { avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( for: .normal, url: avatarImageURL, diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index baaa708a1..32915baf9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -95,7 +95,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { videoPlayerViewModel.didEndDisplaying() } } - if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) { + if let currentAudioAttachment = self.context.audioPlaybackService.attachment, + status?.mediaAttachments?.contains(currentAudioAttachment) == true { self.context.audioPlaybackService.pause() } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index d1c24c97f..abdc27902 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -173,7 +173,7 @@ extension StatusProviderFacade { return (status.objectID, favoriteKind) } .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in - return context.apiService.like( + return context.apiService.favorite( statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, favoriteKind: favoriteKind @@ -201,7 +201,7 @@ extension StatusProviderFacade { } } .map { statusID, favoriteKind in - return context.apiService.like( + return context.apiService.favorite( statusID: statusID, favoriteKind: favoriteKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift new file mode 100644 index 000000000..ecd8291ff --- /dev/null +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -0,0 +1,123 @@ +// +// StatusTableViewControllerAspect.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit +import AVKit + +// Check List Last Updated +// - FavoriteViewController: 2021/4/8 +// - HashtagTimelineViewController: 2021/4/8 +// - UserTimelineViewController: 2021/4/8 +// * StatusTableViewControllerAspect: 2021/4/7 + +// (Fake) Aspect protocol to group common protocol extension implementations +// Needs update related view controller when aspect interface changes + +/// Status related operations aspect +/// Please check the aspect methods (Option+Click) and add hook to implement features +/// - UI +/// - Media +/// - Data Source +protocol StatusTableViewControllerAspect: UIViewController { + var tableView: UITableView { get } +} + +// MARK: - UIViewController [A] + +// [A1] aspectViewWillAppear(_:) +extension StatusTableViewControllerAspect { + /// [UI] hook to deselect row in the transitioning for the table view + func aspectViewWillAppear(_ animated: Bool) { + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } +} + +extension StatusTableViewControllerAspect where Self: NeedsDependency { + /// [Media] hook to notify video service + func aspectViewDidDisappear(_ animated: Bool) { + context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) + } +} + +// MARK: - UITableViewDelegate [B] + +// [B1] aspectTableView(_:estimatedHeightForRowAt:) +extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer { + /// [Data Source] hook to notify table view bottom loader + func aspectScrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +// [B2] aspectTableView(_:estimatedHeightForRowAt:) +extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { + /// [UI] hook to estimate table view cell height from cache + func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + handleTableView(tableView, estimatedHeightForRowAt: indexPath) + } +} + +// [B3] aspectTableView(_:willDisplay:forRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } +} + +// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + /// [Media] hook to notify video service + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider { + /// [UI] hook to cache table view cell height + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider { + /// [Media] hook to notify video service + /// [UI] hook to cache table view cell height + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +// MARK: - UITableViewDataSourcePrefetching [C] + +// [C1] aspectTableView(:prefetchRowsAt) +extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { + /// [Data Source] hook to prefetch reply to info for status + func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D] + +// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) +extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + /// [Media] hook to mark transitioning to video service + func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } +} + +// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) +extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + /// [Media] hook to mark transitioning to video service + func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } +} + diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift index 1b0350086..0907db56f 100644 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -7,6 +7,30 @@ import UIKit -protocol TableViewCellHeightCacheableContainer: UIViewController { - // TODO: +protocol TableViewCellHeightCacheableContainer: StatusProvider { + var cellFrameCache: NSCache { get } +} + +extension TableViewCellHeightCacheableContainer { + + func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let item = item(for: nil, indexPath: indexPath) else { return } + + let key = item.hashValue + let frame = cell.frame + cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + } + + func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let item = item(for: nil, indexPath: indexPath) else { return UITableView.automaticDimension } + guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return UITableView.automaticDimension + } + } + + return ceil(frame.height) + } } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 04297772b..b5f4dd32f 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -139,7 +139,10 @@ extension UserProviderFacade { for mastodonUser: MastodonUser, isMuting: Bool, isBlocking: Bool, - provider: UserProvider + needsShareAction: Bool, + provider: UserProvider, + sourceView: UIView?, + barButtonItem: UIBarButtonItem? ) -> UIMenu { var children: [UIMenuElement] = [] let name = mastodonUser.displayNameWithFallback @@ -198,7 +201,32 @@ extension UserProviderFacade { children.append(blockMenu) } + if needsShareAction { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } + return UIMenu(title: "", options: [], children: children) } + static func createActivityViewControllerForMastodonUser(mastodonUser: MastodonUser, dependency: NeedsDependency) -> UIActivityViewController { + let activityViewController = UIActivityViewController( + activityItems: mastodonUser.activityItems, + applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] + ) + return activityViewController + } + } diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..aa5323a21 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..b4ce9fd5b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 67cb5de00..40000befa 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -24,11 +24,14 @@ Please check your internet connection."; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; "Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; @@ -38,6 +41,7 @@ Please check your internet connection."; "Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Request" = "Request"; "Common.Controls.Firendship.Unblock" = "Unblock"; "Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.Unmute" = "Unmute"; @@ -60,7 +64,8 @@ Please check your internet connection."; until you unblock them. Your account looks like this to them."; "Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; -"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended."; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; @@ -102,6 +107,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Favorite.Title" = "Your Favorites"; "Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; @@ -118,6 +124,7 @@ tap the link to confirm your account."; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Profile.Subtitle" = "%@ posts"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Email" = "Email"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index cefd7b238..c9bf87410 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -41,7 +41,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { let refreshControl = UIRefreshControl() - let titleView = HashtagTimelineNavigationBarTitleView() + let titleView = DoubleTitleLabelNavigationBarTitleView() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) @@ -54,7 +54,7 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashtag)" - titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil) + titleView.update(title: viewModel.hashtag, subtitle: nil) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color @@ -107,13 +107,13 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) - - - } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + viewModel.fetchTag() guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } @@ -123,8 +123,8 @@ extension HashtagTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) + + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -142,7 +142,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle) + titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) } guard let histories = viewModel.hashtagEntity.value?.history else { return @@ -158,9 +158,10 @@ extension HashtagTimelineViewController { .prefix(2) .compactMap({ Int($0.accounts) }) .reduce(0, +) - subtitle = "\(peopleTalkingNumber)" + subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)") } } + } extension HashtagTimelineViewController { @@ -179,11 +180,20 @@ extension HashtagTimelineViewController { } } +// MARK: - StatusTableViewControllerAspect +extension HashtagTimelineViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache + } +} + // MARK: - UIScrollViewDelegate extension HashtagTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) -// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + aspectScrollViewDidScroll(scrollView) } } @@ -197,25 +207,16 @@ extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer // MARK: - UITableViewDelegate extension HashtagTimelineViewController: UITableViewDelegate { - // TODO: - // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - // - // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - // return 200 - // } - // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - // - // return ceil(frame.height) - // } + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } @@ -230,7 +231,7 @@ extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewCont // MARK: - UITableViewDataSourcePrefetching extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) + aspectTableView(tableView, prefetchRowsAt: indexPaths) } } @@ -301,11 +302,11 @@ extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelega extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index d0607550e..e5c78f3d5 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -35,6 +35,8 @@ extension HashtagTimelineViewModel.LoadOldestState { } class Loading: HashtagTimelineViewModel.LoadOldestState { + var maxID: String? + override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self } @@ -54,7 +56,7 @@ extension HashtagTimelineViewModel.LoadOldestState { } // TODO: only set large count when using Wi-Fi - let maxID = last.id + let maxID = self.maxID ?? last.id viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, @@ -71,10 +73,19 @@ extension HashtagTimelineViewModel.LoadOldestState { // handle isFetchingLatestTimeline in fetch controller delegate break } - } receiveValue: { response in + } receiveValue: { [weak self] response in + guard let self = self else { return } + let statuses = response.value // enter no more state when no new statuses - if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() + self.maxID = response.link?.maxID + + if !hasNextPage || statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 785d97264..0c43af79e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -21,10 +21,18 @@ extension HomeTimelineViewController { children: [ moveMenu, dropMenu, + UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) }, + UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -273,9 +281,28 @@ extension HomeTimelineViewController { .store(in: &disposeBag) } + @objc private func showWelcomeAction(_ sender: UIAction) { + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } + @objc private func showProfileAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") + self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 721aae9bc..241a597c1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -66,6 +66,17 @@ extension MastodonPickServerViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + + #if DEBUG + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + let children: [UIMenuElement] = [ + UIAction(title: "Dismiss", image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + }) + ] + navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children) + #endif view.addSubview(nextStepButton) NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 09b6c327c..ed804afd9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -75,6 +75,14 @@ class MastodonPickServerViewModel: NSObject { configure() } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MastodonPickServerViewModel { + private func configure() { Publishers.CombineLatest( filteredIndexedServers.eraseToAnyPublisher(), diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 5c5f77600..b6ba6a8af 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -42,7 +42,7 @@ extension MastodonRegisterViewController { return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) } - private func cropImage(image:UIImage,pickerViewController:UIViewController) { + private func cropImage(image: UIImage, pickerViewController: UIViewController) { DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 6439ea42b..2187ad52b 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -13,6 +13,9 @@ import PhotosUI import UIKit final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { + + static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) + var disposeBag = Set() weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -684,10 +687,10 @@ extension MastodonRegisterViewController { let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value let avatar: Mastodon.Query.MediaAttachment? = { guard let avatarImage = self.viewModel.avatarImage.value else { return nil } - guard avatarImage.size.width <= 400 else { - return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8)) + guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData()) } - return .jpeg(avatarImage.jpegData(compressionQuality: 0.8)) + return .png(avatarImage.pngData()) }() return Mastodon.API.Account.UpdateCredentialQuery( displayName: displayName, diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift new file mode 100644 index 000000000..2dadc8545 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift @@ -0,0 +1,87 @@ +// +// FavoriteViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension FavoriteViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .status(let objectID, _): + let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift new file mode 100644 index 000000000..a175ae348 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -0,0 +1,153 @@ +// +// FavoriteViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +// Note: Prefer use US favorite then EN favourite in coding +// to following the text checker auto-correct behavior + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit + +final class FavoriteViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: FavoriteViewModel! + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension FavoriteViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + navigationItem.titleView = titleView + titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) + } + +} + +// MARK: - StatusTableViewControllerAspect +extension FavoriteViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension FavoriteViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache + } +} + +// MARK: - UIScrollViewDelegate +extension FavoriteViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + aspectScrollViewDidScroll(scrollView) + } +} + +// MARK: - UITableViewDelegate +extension FavoriteViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + +} + +// MARK: - UITableViewDataSourcePrefetching +extension FavoriteViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension FavoriteViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - TimelinePostTableViewCellDelegate +extension FavoriteViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension FavoriteViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = FavoriteViewModel.State.Loading + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} + diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift new file mode 100644 index 000000000..e64df2c99 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -0,0 +1,39 @@ +// +// FavoriteViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit + +extension FavoriteViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + stateMachine.enter(State.Reloading.self) + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift new file mode 100644 index 000000000..c4420e88b --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -0,0 +1,177 @@ +// +// FavoriteViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension FavoriteViewModel { + class State: GKState { + weak var viewModel: FavoriteViewModel? + + init(viewModel: FavoriteViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension FavoriteViewModel.State { + class Initial: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.activeMastodonAuthenticationBox.value != nil + default: + return false + } + } + } + + class Reloading: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: FavoriteViewModel.State { + + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + if previousState is Reloading { + maxID = nil + } + // prefer use `maxID` token in response header + // let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last + + viewModel.context.apiService.favoritedStatuses( + maxID: maxID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + self.maxID = response.link?.maxID + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() + + if hasNewStatusesAppend && hasNextPage { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class NoMore: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift new file mode 100644 index 000000000..589ffe190 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -0,0 +1,101 @@ +// +// FavoriteViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit + +final class FavoriteViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let activeMastodonAuthenticationBox: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + let cellFrameCache = NSCache() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + + init(context: AppContext) { + self.context = context + self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: Status.notDeleted() + ) + + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + statusFetchedResultsController.objectIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + // TODO: handle other states + default: + break + } + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 855581902..38695f34f 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -7,6 +7,11 @@ import os.log import UIKit +import Combine +import PhotosUI +import AlamofireImage +import CropViewController +import TwitterTextEditor protocol ProfileHeaderViewControllerDelegate: class { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -19,8 +24,21 @@ final class ProfileHeaderViewController: UIViewController { static let segmentedControlMarginHeight: CGFloat = 20 static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + var disposeBag = Set() weak var delegate: ProfileHeaderViewControllerDelegate? + var viewModel: ProfileHeaderViewModel! + + let titleView: DoubleTitleLabelNavigationBarTitleView = { + let titleView = DoubleTitleLabelNavigationBarTitleView() + titleView.titleLabel.textColor = .white + titleView.titleLabel.alpha = 0 + titleView.subtitleLabel.textColor = .white + titleView.subtitleLabel.alpha = 0 + titleView.layer.masksToBounds = true + return titleView + }() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) @@ -33,6 +51,28 @@ final class ProfileHeaderViewController: UIViewController { // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero + + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) + documentPickerController.delegate = self + return documentPickerController + }() deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -67,11 +107,99 @@ extension ProfileHeaderViewController { ]) pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + + Publishers.CombineLatest( + viewModel.viewDidAppear.eraseToAnyPublisher(), + viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in + guard let self = self else { return } + self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + } + .store(in: &disposeBag) + + viewModel.needsSetupBottomShadow + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupBottomShadow in + guard let self = self else { return } + self.setupBottomShadow() + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(), + viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, resource, editingResource, _ in + guard let self = self else { return } + let url: URL? = { + guard case let .url(url) = resource else { return nil } + return url + + }() + let image: UIImage? = { + guard case let .image(image) = editingResource else { return nil } + return image + }() + self.profileHeaderView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: image == nil ? url : nil, // set only when image empty + placeholderImage: image, + borderColor: .white, + borderWidth: 2 + ) + ) + } + .store(in: &disposeBag) + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, name, editingName in + guard let self = self else { return } + self.profileHeaderView.nameTextField.text = isEditing ? editingName : name + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, note, editingNote in + guard let self = self else { return } + self.profileHeaderView.bioActiveLabel.configure(note: note ?? "") + self.profileHeaderView.bioTextEditorView.text = editingNote ?? "" + } + .store(in: &disposeBag) + + profileHeaderView.bioTextEditorView.changeObserver = self + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self = self else { return } + guard let textField = notification.object as? UITextField else { return } + self.viewModel.editProfileInfo.name.value = textField.text + } + .store(in: &disposeBag) + + profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() + profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + viewModel.viewDidAppear.value = true + // Deprecated: // not needs this tweak due to force layout update in the parent // if !isAdjustBannerImageViewForSafeAreaInset { @@ -85,11 +213,52 @@ extension ProfileHeaderViewController { super.viewDidLayoutSubviews() delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) - view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + setupBottomShadow() } } +extension ProfileHeaderViewController { + private func createAvatarContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePicker, animated: true, completion: nil) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePickerController, animated: true, completion: nil) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.documentPickerController, animated: true, completion: nil) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func cropImage(image: UIImage, pickerViewController: UIViewController) { + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true + cropController.aspectRatioLockEnabled = true + pickerViewController.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } +} + extension ProfileHeaderViewController { @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { @@ -105,6 +274,15 @@ extension ProfileHeaderViewController { containerSafeAreaInset = inset } + func setupBottomShadow() { + guard viewModel.needsSetupBottomShadow.value else { + view.layer.shadowColor = nil + view.layer.shadowRadius = 0 + return + } + view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + } + private func updateHeaderBottomShadow(progress: CGFloat) { let alpha = min(max(0, 10 * progress - 9), 1) if bottomShadowAlpha != alpha { @@ -125,20 +303,133 @@ extension ProfileHeaderViewController { let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height - + + // scroll from bottom to top: 1 -> 2 -> 3 if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { + // 1 + // banner top pin to window top and expand bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height } else if bannerContainerBottomOffset < containerSafeAreaInset.top { + // 3 + // banner bottom pin to navigation bar bottom and + // the `progress` growth to 1 then segemented control pin to top bannerImageView.frame.origin.y = -containerSafeAreaInset.top let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) bannerImageView.frame.size.height = bannerImageHeight } else { + // 2 + // banner move with scrolling from bottom to top until the + // banner bottom higher than navigation bar bottom bannerImageView.frame.origin.y = -containerSafeAreaInset.top bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top } - // TODO: handle titleView + // set title view offset + let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) + let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y + let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset + titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset)) + + if viewModel.viewDidAppear.value { + viewModel.isTitleViewContentOffsetSet.value = true + } + + // set avatar + if progress > 0 { + setProfileBannerFade(alpha: 0) + } else if progress > -0.3 { + // y = -(10/3)x + let alpha = -10.0 / 3.0 * progress + setProfileBannerFade(alpha: alpha) + } else { + setProfileBannerFade(alpha: 1) + } + } + + private func setProfileBannerFade(alpha: CGFloat) { + profileHeaderView.avatarImageView.alpha = alpha + profileHeaderView.editAvatarBackgroundView.alpha = alpha + profileHeaderView.nameTextFieldBackgroundView.alpha = alpha + profileHeaderView.nameTextField.alpha = alpha + profileHeaderView.usernameLabel.alpha = alpha } } + +// MARK: - TextEditorViewChangeObserver +extension ProfileHeaderViewController: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) + guard changeResult.isTextChanged else { return } + assert(textEditorView === profileHeaderView.bioTextEditorView) + viewModel.editProfileInfo.note.value = textEditorView.text + } +} + +// MARK: - PHPickerViewControllerDelegate +extension ProfileHeaderViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + guard let result = results.first else { return } + PHPickerResultLoader.loadImageData(from: result) + .sink { [weak self] completion in + guard let _ = self else { return } + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + guard let imageData = imageData else { return } + guard let image = UIImage(data: imageData) else { return } + self.cropImage(image: image, pickerViewController: picker) + } + .store(in: &disposeBag) + } +} + +// MARK: - UIImagePickerControllerDelegate +extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + cropImage(image: image, pickerViewController: picker) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ProfileHeaderViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + guard let image = UIImage(data: imageData) else { return } + cropImage(image: image, pickerViewController: controller) + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + +// MARK: - CropViewControllerDelegate +extension ProfileHeaderViewController: CropViewControllerDelegate { + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + viewModel.editProfileInfo.avatarImageResource.value = .image(image) + cropViewController.dismiss(animated: true, completion: nil) + } +} + diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift new file mode 100644 index 000000000..eb4a054b8 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -0,0 +1,115 @@ +// +// ProfileHeaderViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-9. +// + +import UIKit +import Combine +import Kanna +import MastodonSDK + +final class ProfileHeaderViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let isEditing = CurrentValueSubject(false) + let viewDidAppear = CurrentValueSubject(false) + let needsSetupBottomShadow = CurrentValueSubject(true) + let isTitleViewContentOffsetSet = CurrentValueSubject(false) + + // output + let displayProfileInfo = ProfileInfo() + let editProfileInfo = ProfileInfo() + + init(context: AppContext) { + self.context = context + + isEditing + .removeDuplicates() // only triiger when value toggle + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing in + guard let self = self else { return } + // setup editing value when toggle to editing + self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name + self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty + self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value) + } + .store(in: &disposeBag) + } + +} + +extension ProfileHeaderViewModel { + struct ProfileInfo { + let name = CurrentValueSubject(nil) + let avatarImageResource = CurrentValueSubject(nil) + let note = CurrentValueSubject(nil) + + enum ImageResource { + case url(URL?) + case image(UIImage?) + } + } +} + +extension ProfileHeaderViewModel { + + static func normalize(note: String?) -> String? { + guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else { + return nil + } + + let html = try? HTML(html: note, encoding: .utf8) + return html?.text + } + + // check if profile chagned or not + func isProfileInfoEdited() -> Bool { + guard isEditing.value else { return false } + + guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true } + guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true } + guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true } + + return false + } + + func updateProfileInfo() -> AnyPublisher, Error> { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher() + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + + let image: UIImage? = { + guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil } + guard let image = _image else { return nil } + guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel) + } + return image + }() + + let query = Mastodon.API.Account.UpdateCredentialQuery( + discoverable: nil, + bot: nil, + displayName: editProfileInfo.name.value, + note: editProfileInfo.note.value, + avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + header: nil, + locked: nil, + source: nil, + fieldsAttributes: nil // TODO: + ) + return context.apiService.accountUpdateCredentials( + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index bf292ac45..2fba55e66 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -8,6 +8,7 @@ import os.log import UIKit import ActiveLabel +import TwitterTextEditor protocol ProfileHeaderViewDelegate: class { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) @@ -25,8 +26,13 @@ final class ProfileHeaderView: UIView { static let friendshipActionButtonSize = CGSize(width: 108, height: 34) static let bannerImageViewPlaceholderColor = UIColor.systemGray + static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5) + static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8) + weak var delegate: ProfileHeaderViewDelegate? + var state: State? + let bannerContainerView = UIView() let bannerImageView: UIImageView = { let imageView = UIImageView() @@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView { }() let bannerImageViewOverlayView: UIView = { let overlayView = UIView() - overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor return overlayView }() @@ -53,16 +59,40 @@ final class ProfileHeaderView: UIView { imageView.image = placeholderImage return imageView }() + + let editAvatarBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.6) + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + return view + }() + + let editAvatarButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) + button.tintColor = .white + return button + }() - let nameLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.5 - label.textColor = .white - 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 nameTextFieldBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = 10 + return view + }() + + let nameTextField: UITextField = { + let textField = UITextField() + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + textField.textColor = .white + textField.text = "Alice" + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return textField }() let usernameLabel: UILabel = { @@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView { }() let bioContainerView = UIView() + let bioContainerStackView = UIStackView() let fieldContainerStackView = UIStackView() + let bioActiveLabelContainer: UIView = { + // use to set margin for active label + // the display/edit mode bio transition animation should without flicker with that + let view = UIView() + // note: comment out to see how it works + view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView + return view + }() let bioActiveLabel = ActiveLabel(style: .default) + let bioTextEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + textEditorView.font = .preferredFont(forTextStyle: .body) + textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + textEditorView.layer.masksToBounds = true + textEditorView.layer.cornerCurve = .continuous + textEditorView.layer.cornerRadius = 10 + return textEditorView + }() override init(frame: CGRect) { super.init(frame: frame) @@ -137,12 +187,32 @@ extension ProfileHeaderView { avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) + + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.addSubview(editAvatarBackgroundView) + NSLayoutConstraint.activate([ + editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor), + editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), + editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + ]) + + editAvatarButton.translatesAutoresizingMaskIntoConstraints = false + editAvatarBackgroundView.addSubview(editAvatarButton) + NSLayoutConstraint.activate([ + editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), + editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), + editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), + editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), + ]) + editAvatarBackgroundView.isUserInteractionEnabled = true + avatarImageView.isUserInteractionEnabled = true - // name container: [display name | username] + // name container: [display name container | username] let nameContainerStackView = UIStackView() nameContainerStackView.preservesSuperviewLayoutMargins = true nameContainerStackView.axis = .vertical - nameContainerStackView.spacing = 0 + nameContainerStackView.spacing = 7 nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(nameContainerStackView) NSLayoutConstraint.activate([ @@ -150,7 +220,27 @@ extension ProfileHeaderView { nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), ]) - nameContainerStackView.addArrangedSubview(nameLabel) + + let displayNameStackView = UIStackView() + displayNameStackView.axis = .horizontal + nameTextField.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addArrangedSubview(nameTextField) + NSLayoutConstraint.activate([ + nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal) + nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addSubview(nameTextFieldBackgroundView) + NSLayoutConstraint.activate([ + nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5), + nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5), + nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5), + nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5), + ]) + displayNameStackView.bringSubviewToFront(nameTextField) + displayNameStackView.addArrangedSubview(UIView()) + + nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) // meta container: [dashboard container | bio container | field container] @@ -192,15 +282,29 @@ extension ProfileHeaderView { bioContainerView.preservesSuperviewLayoutMargins = true metaContainerStackView.addArrangedSubview(bioContainerView) - bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false - bioContainerView.addSubview(bioActiveLabel) + + bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false + bioContainerView.addSubview(bioContainerStackView) NSLayoutConstraint.activate([ - bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor), - bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), - bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), - bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), + bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor), + bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), + bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), + bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), ]) + bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false + bioActiveLabelContainer.addSubview(bioActiveLabel) + NSLayoutConstraint.activate([ + bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor), + bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor), + bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor), + bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor), + ]) + + bioContainerStackView.axis = .vertical + bioContainerStackView.addArrangedSubview(bioActiveLabelContainer) + bioContainerStackView.addArrangedSubview(bioTextEditorView) + fieldContainerStackView.preservesSuperviewLayoutMargins = true metaContainerStackView.addSubview(fieldContainerStackView) @@ -210,10 +314,58 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) + + configure(state: .normal) } } +extension ProfileHeaderView { + enum State { + case normal + case editing + } + + func configure(state: State) { + guard self.state != state else { return } // avoid redundant animation + self.state = state + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + + switch state { + case .normal: + nameTextField.isEnabled = false + bioActiveLabelContainer.isHidden = false + bioTextEditorView.isHidden = true + + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editAvatarBackgroundView.alpha = 0 + } + animator.addCompletion { _ in + self.editAvatarBackgroundView.isHidden = true + } + case .editing: + nameTextField.isEnabled = true + bioActiveLabelContainer.isHidden = true + bioTextEditorView.isHidden = false + + editAvatarBackgroundView.isHidden = false + editAvatarBackgroundView.alpha = 0 + bioTextEditorView.backgroundColor = .clear + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor + self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color + self.editAvatarBackgroundView.alpha = 1 + self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + } + } + + animator.startAnimation() + } +} + 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) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index 0f6a804b5..d4b57ffe4 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -9,6 +9,12 @@ import UIKit final class ProfileRelationshipActionButton: RoundedEdgesButton { + let actvityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + return activityIndicatorView + }() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton { extension ProfileRelationshipActionButton { private func _init() { - // do nothing + actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(actvityIndicatorView) + NSLayoutConstraint.activate([ + actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + actvityIndicatorView.hidesWhenStopped = true + actvityIndicatorView.stopAnimating() } } @@ -34,10 +48,15 @@ extension ProfileRelationshipActionButton { setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { + actvityIndicatorView.stopAnimating() + + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { isEnabled = false + } else if actionOptionSet.contains(.updating) { + isEnabled = false + actvityIndicatorView.startAnimating() } else { isEnabled = true } diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift index 36bcf0b4e..d1c0cb49d 100644 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel { self.currentMastodonUser .sink { [weak self] currentMastodonUser in - os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") guard let self = self else { return } self.mastodonUser.value = currentMastodonUser diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 59cf4809f..671f7c155 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,6 +18,30 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + 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 @@ -54,12 +78,20 @@ final class ProfileViewController: UIViewController, NeedsDependency { }() private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() - private(set) lazy var profileHeaderViewController = ProfileHeaderViewController() + private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { + let viewController = ProfileHeaderViewController() + viewController.viewModel = ProfileHeaderViewModel(context: context) + return viewController + }() private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! private var contentOffsets: [Int: CGFloat] = [:] var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + // title view nested in header + var titleView: DoubleTitleLabelNavigationBarTitleView { + profileHeaderViewController.titleView + } deinit { os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) @@ -116,27 +148,66 @@ extension ProfileViewController { navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance - navigationItem.titleView = UIView() + navigationItem.titleView = titleView + + let editingAndUpdatingPublisher = Publishers.CombineLatest( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() + ) + // note: not add .share() here - Publishers.CombineLatest( + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( + viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() ) + + editingAndUpdatingPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, isUpdating in + guard let self = self else { return } + self.cancelEditingBarButtonItem.isEnabled = !isUpdating + } + .store(in: &disposeBag) + + Publishers.CombineLatest3 ( + viewModel.suspended.eraseToAnyPublisher(), + editingAndUpdatingPublisher.eraseToAnyPublisher(), + barButtonItemHiddenPublisher.eraseToAnyPublisher() + ) .receive(on: DispatchQueue.main) - .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + .sink { [weak self] suspended, tuple1, tuple2 in guard let self = self else { return } + let (isEditing, _) = tuple1 + let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 + var items: [UIBarButtonItem] = [] + defer { + self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil + } + + guard !suspended else { + return + } + + guard !isEditing else { + items.append(self.cancelEditingBarButtonItem) + return + } + + guard isMeBarButtonItemsHidden else { + items.append(self.settingBarButtonItem) + items.append(self.shareBarButtonItem) + items.append(self.favoriteBarButtonItem) + return + } + 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) @@ -225,6 +296,23 @@ extension ProfileViewController { profileSegmentedViewController.pagingViewController.pagingDelegate = self // bind view model + Publishers.CombineLatest( + viewModel.name.eraseToAnyPublisher(), + viewModel.statusesCount.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] name, statusesCount in + guard let self = self else { return } + guard let title = name, let statusesCount = statusesCount, + let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { + self.titleView.isHidden = true + return + } + let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) + self.titleView.update(title: title, subtitle: subtitle) + self.titleView.isHidden = false + } + .store(in: &disposeBag) viewModel.name .receive(on: DispatchQueue.main) .sink { [weak self] name in @@ -263,22 +351,15 @@ extension ProfileViewController { ) } .store(in: &disposeBag) - Publishers.CombineLatest( - viewModel.avatarImageURL.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] avatarImageURL, _ in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.configure( - with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2) - ) - } - .store(in: &disposeBag) - viewModel.name - .map { $0 ?? " " } + viewModel.avatarImageURL .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) + .map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) } + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource) + .store(in: &disposeBag) + viewModel.name + .map { $0 ?? "" } + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) .store(in: &disposeBag) viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } @@ -295,7 +376,8 @@ extension ProfileViewController { } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) - self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self) + let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, needsShareAction: needsShareAction, provider: self, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem) } .store(in: &disposeBag) viewModel.isRelationshipActionButtonHidden @@ -305,27 +387,60 @@ extension ProfileViewController { self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden } .store(in: &disposeBag) - Publishers.CombineLatest( + Publishers.CombineLatest3( viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), - viewModel.isEditing.eraseToAnyPublisher() + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionSet, isEditing in + .sink { [weak self] relationshipActionSet, isEditing, isUpdating in guard let self = self else { return } let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton if relationshipActionSet.contains(.edit) { - friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit) + // check .edit state and set .editing when isEditing + friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) + self.profileHeaderViewController.profileHeaderView.configure(state: isUpdating || isEditing ? .editing : .normal) } else { friendshipButton.configure(actionOptionSet: relationshipActionSet) } } .store(in: &disposeBag) + viewModel.isEditing + .handleEvents(receiveOutput: { [weak self] isEditing in + guard let self = self else { return } + // dismiss keyboard if needs + if !isEditing { self.view.endEditing(true) } + + self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing + self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0 + self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 + } + animator.startAnimation() + }) + .assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing) + .store(in: &disposeBag) + Publishers.CombineLatest3( + viewModel.isBlocking.eraseToAnyPublisher(), + viewModel.isBlockedBy.eraseToAnyPublisher(), + viewModel.suspended.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isBlocking, isBlockedBy, suspended in + guard let self = self else { return } + let isNeedSetHidden = isBlocking || isBlockedBy || suspended + self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden + self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden + self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden + self.viewModel.needsPagePinToTop.value = isNeedSetHidden + } + .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] bio in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "") - }) + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note) .store(in: &disposeBag) viewModel.statusesCount .sink { [weak self] count in @@ -374,6 +489,7 @@ extension ProfileViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + currentPostTimelineTableViewContentSizeObservation = nil } @@ -386,12 +502,45 @@ extension ProfileViewController { viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) + viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) } } extension ProfileViewController { + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + viewModel.isEditing.value = false + } + + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + } + + @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let activityViewController = UserProviderFacade.createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: self) + coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: nil, + barButtonItem: sender + ), + from: self, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let favoriteViewModel = FavoriteViewModel(context: context) + coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) + } + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let mastodonUser = viewModel.mastodonUser.value else { return } @@ -414,11 +563,6 @@ extension ProfileViewController { sender.endRefreshing() } } - -// @objc private func avatarButtonPressed(_ sender: UIButton) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) -// } } @@ -436,10 +580,15 @@ extension ProfileViewController: UIScrollViewDelegate { contentOffsets.removeAll() } else { containerScrollView.contentOffset.y = topMaxContentOffsetY - if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { - let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y - customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + if viewModel.needsPagePinToTop.value { + // do nothing + } else { + if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { + let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y + customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + } } + } // elastically banner image @@ -492,22 +641,42 @@ extension ProfileViewController: ProfileHeaderViewDelegate { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { - viewModel.isEditing.value.toggle() + guard !viewModel.isUpdating.value else { return } + + if profileHeaderViewController.viewModel.isProfileInfoEdited() { + viewModel.isUpdating.value = true + profileHeaderViewController.viewModel.updateProfileInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function) + } + self.viewModel.isUpdating.value = false + } receiveValue: { [weak self] _ in + guard let self = self else { return } + self.viewModel.isEditing.value = false + } + .store(in: &disposeBag) + } else { + viewModel.isEditing.value.toggle() + } } else { guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } switch relationshipAction { case .none: break - case .follow, .following: + case .follow, .reqeust, .pending, .following: UserProviderFacade.toggleUserFollowRelationship(provider: self) .sink { _ in - + // TODO: handle error } receiveValue: { _ in - + // do nothing } .store(in: &disposeBag) - case .pending: - break case .muting: guard let mastodonUser = viewModel.mastodonUser.value else { return } let name = mastodonUser.displayNameWithFallback @@ -557,9 +726,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { default: assertionFailure() } - } - } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 057e18030..445952e96 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -41,10 +41,12 @@ class ProfileViewModel: NSObject { let followersCount: CurrentValueSubject let protected: CurrentValueSubject - // let suspended: CurrentValueSubject + let suspended: CurrentValueSubject - let relationshipActionOptionSet = CurrentValueSubject(.none) let isEditing = CurrentValueSubject(false) + let isUpdating = CurrentValueSubject(false) + + let relationshipActionOptionSet = CurrentValueSubject(.none) let isFollowedBy = CurrentValueSubject(false) let isMuting = CurrentValueSubject(false) let isBlocking = CurrentValueSubject(false) @@ -53,6 +55,9 @@ class ProfileViewModel: NSObject { let isRelationshipActionButtonHidden = CurrentValueSubject(true) let isReplyBarButtonItemHidden = CurrentValueSubject(true) let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) + let isMeBarButtonItemsHidden = CurrentValueSubject(true) + + let needsPagePinToTop = CurrentValueSubject(false) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context @@ -61,7 +66,6 @@ class ProfileViewModel: NSObject { self.userID = CurrentValueSubject(mastodonUser?.id) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) -// self.protected = CurrentValueSubject(twitterUser?.protected) self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.bioDescription = CurrentValueSubject(mastodonUser?.note) @@ -70,6 +74,7 @@ class ProfileViewModel: NSObject { self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.protected = CurrentValueSubject(mastodonUser?.locked) + self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) super.init() relationshipActionOptionSet @@ -225,6 +230,7 @@ extension ProfileViewModel { self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.protected.value = mastodonUser?.locked + self.suspended.value = mastodonUser?.suspended ?? false } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { @@ -240,6 +246,7 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = true return } @@ -248,10 +255,19 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = false } else { // set with follow action default var relationshipActionSet = RelationshipActionOptionSet([.follow]) + if mastodonUser.locked { + relationshipActionSet.insert(.request) + } + + if mastodonUser.suspended { + relationshipActionSet.insert(.suspended) + } + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false if isFollowing { relationshipActionSet.insert(.following) @@ -294,6 +310,7 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy self.isMoreMenuBarButtonItemHidden.value = false + self.isMeBarButtonItemsHidden.value = true } } @@ -304,13 +321,16 @@ extension ProfileViewModel { enum RelationshipAction: Int, CaseIterable { case none // set hide from UI case follow + case reqeust case pending case following case muting case blocked case blocking + case suspended case edit case editing + case updating var option: RelationshipActionOptionSet { return RelationshipActionOptionSet(rawValue: 1 << rawValue) @@ -323,15 +343,18 @@ extension ProfileViewModel { static let none = RelationshipAction.none.option static let follow = RelationshipAction.follow.option + static let request = RelationshipAction.reqeust.option static let pending = RelationshipAction.pending.option static let following = RelationshipAction.following.option static let muting = RelationshipAction.muting.option static let blocked = RelationshipAction.blocked.option static let blocking = RelationshipAction.blocking.option + static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option + static let updating = RelationshipAction.updating.option - static let editOptions: RelationshipActionOptionSet = [.edit, .editing] + static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating] func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { let set = subtracting(except) @@ -350,13 +373,16 @@ extension ProfileViewModel { switch highPriorityAction { case .none: return " " case .follow: return L10n.Common.Controls.Firendship.follow + case .reqeust: return L10n.Common.Controls.Firendship.request 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 .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocking: return L10n.Common.Controls.Firendship.blocked + case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done + case .updating: return " " } } @@ -368,13 +394,16 @@ extension ProfileViewModel { switch highPriorityAction { case .none: return Asset.Colors.Button.normal.color case .follow: return Asset.Colors.Button.normal.color + case .reqeust: 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 .blocked: return Asset.Colors.Button.disabled.color + case .blocked: return Asset.Colors.Button.normal.color case .blocking: return Asset.Colors.Background.danger.color + case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color + case .updating: return Asset.Colors.Button.normal.color } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 442f57cce..e8e71ccf4 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -57,6 +57,7 @@ extension UserTimelineViewController { ]) tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -80,21 +81,31 @@ extension UserTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - tableView.deselectRow(with: transitionCoordinator, animated: animated) + aspectViewWillAppear(animated) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) + aspectViewDidDisappear(animated) } } +// MARK: - StatusTableViewControllerAspect +extension UserTimelineViewController: StatusTableViewControllerAspect { } + // MARK: - UIScrollViewDelegate extension UserTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) + aspectScrollViewDidScroll(scrollView) + } +} + +// MARK: - TableViewCellHeightCacheableContainer +extension UserTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache } } @@ -102,41 +113,35 @@ extension UserTimelineViewController { extension UserTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - - guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - if case .bottomLoader = item { - return TimelineLoaderTableViewCell.cellHeight - } else { - return 200 - } - } - // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - - return ceil(frame.height) + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - let key = item.hashValue - let frame = cell.frame - viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } +// MARK: - UITableViewDataSourcePrefetching +extension UserTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - AVPlayerViewControllerDelegate extension UserTimelineViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } } @@ -147,10 +152,6 @@ extension UserTimelineViewController: StatusTableViewCellDelegate { func parent() -> UIViewController { return self } } -//// MARK: - TimelineHeaderTableViewCellDelegate -//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { } - - // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { var scrollView: UIScrollView { return tableView } @@ -159,7 +160,7 @@ extension UserTimelineViewController: ScrollViewContainer { // MARK: - LoadMoreConfigurableTableViewContainer extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = UserTimelineViewModel.State.LoadingMore + typealias LoadingState = UserTimelineViewModel.State.Loading var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index f31f52400..be06d781a 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -40,11 +40,7 @@ extension UserTimelineViewModel.State { class Reloading: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Fail.Type: - return true - case is Idle.Type: - return true - case is NoMore.Type: + case is Loading.Type: return true default: return false @@ -57,69 +53,38 @@ extension UserTimelineViewModel.State { // reset viewModel.statusFetchedResultsController.statusIDs.value = [] - - guard let userID = viewModel.userID.value, !userID.isEmpty else { - stateMachine.enter(Fail.self) - return - } - - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - let domain = activeMastodonAuthenticationBox.domain - let queryFilter = viewModel.queryFilter.value - - viewModel.context.apiService.userTimeline( - domain: domain, - accountID: userID, - maxID: nil, - sinceID: nil, - excludeReplies: queryFilter.excludeReplies, - excludeReblogs: queryFilter.excludeReblogs, - onlyMedia: queryFilter.onlyMedia, - authorizationBox: activeMastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .sink { completion in - - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value - for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) - hasNewStatusesAppend = true - } - - if hasNewStatusesAppend { - stateMachine.enter(Idle.self) - } else { - stateMachine.enter(NoMore.self) - } - viewModel.statusFetchedResultsController.statusIDs.value = statusIDs - } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Loading.self) } } class Fail: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type, is LoadingMore.Type: + case is Loading.Type: return true default: return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } } class Idle: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type, is LoadingMore.Type: + case is Reloading.Type, is Loading.Type: return true default: return false @@ -127,7 +92,7 @@ extension UserTimelineViewModel.State { } } - class LoadingMore: UserTimelineViewModel.State { + class Loading: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Fail.Type: @@ -145,10 +110,7 @@ extension UserTimelineViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else { - stateMachine.enter(Fail.self) - return - } + let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last guard let userID = viewModel.userID.value, !userID.isEmpty else { stateMachine.enter(Fail.self) @@ -177,6 +139,7 @@ extension UserTimelineViewModel.State { switch completion { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) case .finished: break } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 2276db5fe..03e5e627d 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -12,9 +12,8 @@ import Combine import CoreData import CoreDataStack import MastodonSDK -import AlamofireImage -class UserTimelineViewModel: NSObject { +final class UserTimelineViewModel { var disposeBag = Set() @@ -28,6 +27,8 @@ class UserTimelineViewModel: NSObject { let isBlocking = CurrentValueSubject(false) let isBlockedBy = CurrentValueSubject(false) + let isSuspended = CurrentValueSubject(false) + let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label // output var diffableDataSource: UITableViewDiffableDataSource? @@ -37,7 +38,7 @@ class UserTimelineViewModel: NSObject { State.Reloading(viewModel: self), State.Fail(viewModel: self), State.Idle(viewModel: self), - State.LoadingMore(viewModel: self), + State.Loading(viewModel: self), State.NoMore(viewModel: self), ]) stateMachine.enter(State.Initial.self) @@ -54,20 +55,21 @@ class UserTimelineViewModel: NSObject { self.domain = CurrentValueSubject(domain) self.userID = CurrentValueSubject(userID) self.queryFilter = CurrentValueSubject(queryFilter) - super.init() + // super.init() self.domain .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - Publishers.CombineLatest3( + Publishers.CombineLatest4( statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), isBlocking.eraseToAnyPublisher(), - isBlockedBy.eraseToAnyPublisher() + isBlockedBy.eraseToAnyPublisher(), + isSuspended.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] objectIDs, isBlocking, isBlockedBy in + .sink { [weak self] objectIDs, isBlocking, isBlockedBy, isSuspended in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } @@ -90,6 +92,12 @@ class UserTimelineViewModel: NSObject { return } + let name = self.userDisplayName.value + guard !isSuspended else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .suspended(name: name)))], toSection: .main) + return + } + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] let oldSnapshot = diffableDataSource.snapshot() for item in oldSnapshot.itemIdentifiers { @@ -105,7 +113,7 @@ class UserTimelineViewModel: NSObject { if let currentState = self.stateMachine.currentState { switch currentState { - case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: snapshot.appendItems([.bottomLoader], toSection: .main) case is State.NoMore: break @@ -114,8 +122,6 @@ class UserTimelineViewModel: NSObject { break } } - - } .store(in: &disposeBag) } @@ -144,4 +150,3 @@ extension UserTimelineViewModel { } } - diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift similarity index 72% rename from Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift rename to Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift index 78d5a971c..b136859a8 100644 --- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -1,5 +1,5 @@ // -// HashtagTimelineTitleView.swift +// DoubleTitleLabelNavigationBarTitleView.swift // Mastodon // // Created by BradGao on 2021/4/1. @@ -7,7 +7,7 @@ import UIKit -final class HashtagTimelineNavigationBarTitleView: UIView { +final class DoubleTitleLabelNavigationBarTitleView: UIView { let containerView = UIStackView() @@ -40,7 +40,7 @@ final class HashtagTimelineNavigationBarTitleView: UIView { } -extension HashtagTimelineNavigationBarTitleView { +extension DoubleTitleLabelNavigationBarTitleView { private func _init() { containerView.axis = .vertical containerView.alignment = .center @@ -58,10 +58,10 @@ extension HashtagTimelineNavigationBarTitleView { containerView.addArrangedSubview(subtitleLabel) } - func updateTitle(hashtag: String, peopleNumber: String?) { - titleLabel.text = "#\(hashtag)" - if let peopleNumebr = peopleNumber { - subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) + func update(title: String, subtitle: String?) { + titleLabel.text = title + if let subtitle = subtitle { + subtitleLabel.text = subtitle subtitleLabel.isHidden = false } else { subtitleLabel.text = nil @@ -69,3 +69,21 @@ extension HashtagTimelineNavigationBarTitleView { } } } + +#if canImport(SwiftUI) && DEBUG + +import SwiftUI + +struct DoubleTitleLabelNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + DoubleTitleLabelNavigationBarTitleView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index e253b3ca7..b5e4c5bde 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -84,8 +84,10 @@ extension TimelineHeaderView { extension Item.EmptyStateHeaderAttribute.Reason { var iconImage: UIImage? { switch self { - case .noStatusFound, .blocking, .blocked, .suspended: + case .noStatusFound, .blocking, .blocked: return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! + case .suspended: + return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! } } @@ -97,8 +99,12 @@ extension Item.EmptyStateHeaderAttribute.Reason { return L10n.Common.Controls.Timeline.Header.blockingWarning case .blocked: return L10n.Common.Controls.Timeline.Header.blockedWarning - case .suspended: - return L10n.Common.Controls.Timeline.Header.suspendedWarning + case .suspended(let name): + if let name = name { + return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name) + } else { + return L10n.Common.Controls.Timeline.Header.suspendedWarning + } } } } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index d49a7371b..23206494e 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -16,7 +16,7 @@ import CommonOSLog extension APIService { // make local state change only - func like( + func favorite( statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, favoriteKind: Mastodon.API.Favorites.FavoriteKind @@ -50,7 +50,7 @@ extension APIService { } // send favorite request to remote - func like( + func favorite( statusID: Mastodon.Entity.Status.ID, favoriteKind: Mastodon.API.Favorites.FavoriteKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -128,16 +128,20 @@ extension APIService { } extension APIService { - func likeList( + func favoritedStatuses( limit: Int = onceRequestStatusMaxCount, - userID: String, maxID: String? = nil, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let requestMastodonUserID = mastodonAuthenticationBox.userID - let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID) + return Mastodon.API.Favorites.favoritedStatus( + domain: mastodonAuthenticationBox.domain, + session: session, + authorization: mastodonAuthenticationBox.userAuthorization, + query: query + ) .map { response -> AnyPublisher, Error> in let log = OSLog.api diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index f2c57db57..cf19878d6 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -158,6 +158,7 @@ extension APIService { ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID return Mastodon.API.Account.follow( session: session, @@ -166,22 +167,50 @@ extension APIService { followQueryType: followQueryType, authorization: authorization ) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let _ = self else { return } - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - break - case .finished: - switch followQueryType { - case .follow: - break - case .unfollow: - break +// .handleEvents(receiveCompletion: { [weak self] completion in +// guard let _ = self else { return } +// switch completion { +// case .failure(let error): +// // TODO: handle error +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// break +// case .finished: +// switch followQueryType { +// case .follow: +// break +// case .unfollow: +// break +// } +// } +// }) + .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, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) } } - }) + .tryMap { result -> Mastodon.Response.Content 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 fdac2a2a6..4a1237051 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -95,6 +95,9 @@ extension APIService.CoreData { user.update(statusesCount: property.statusesCount) user.update(followingCount: property.followingCount) user.update(followersCount: property.followersCount) + user.update(locked: property.locked) + property.bot.flatMap { user.update(bot: $0) } + property.suspended.flatMap { user.update(suspended: $0) } user.didUpdate(at: networkDate) } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index dfd41094a..9bc699b71 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -234,8 +234,8 @@ extension APIService.Persist { let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in return next.statusProcessType == .create ? result + 1 : result }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) } #endif } diff --git a/Mastodon/ViewController.swift b/Mastodon/ViewController.swift deleted file mode 100644 index 9be4589cc..000000000 --- a/Mastodon/ViewController.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-31. -// - -import UIKit - -class ViewController: UIViewController { - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } -} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 64598bc14..01b9d61a4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -14,78 +14,6 @@ extension Mastodon.API.Favorites { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") } - static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { - let pathComponent = "statuses/" + statusID + "/favourited_by" - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { - var actionString: String - switch favoriteKind { - case .create: - actionString = "/favourite" - case .destroy: - actionString = "/unfavourite" - } - let pathComponent = "statuses/" + statusID + actionString - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - /// Favourite / Undo Favourite - /// - /// Add a status to your favourites list / Remove a status from your favourites list - /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/3/3 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/statuses/) - /// - Parameters: - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - statusID: Mastodon status id - /// - session: `URLSession` - /// - authorization: User token - /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { - let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) - var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Favourited by - /// - /// View who favourited a given status. - /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/3/3 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/statuses/) - /// - Parameters: - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - statusID: Mastodon status id - /// - session: `URLSession` - /// - authorization: User token - /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { - let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) - let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - /// Favourited statuses /// /// Using this endpoint to view the favourited list for user @@ -101,7 +29,12 @@ extension Mastodon.API.Favorites { /// - session: `URLSession` /// - authorization: User token /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + public static func favoritedStatus( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.Favorites.FavoriteStatusesQuery + ) -> AnyPublisher, Error> { let url = favoritesStatusesEndpointURL(domain: domain) let request = Mastodon.API.get(url: url, query: query, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -112,16 +45,7 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } -} - -extension Mastodon.API.Favorites { - - public enum FavoriteKind { - case create - case destroy - } - - public struct ListQuery: GetQuery, PagedQueryType { + public struct FavoriteStatusesQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? @@ -155,3 +79,99 @@ extension Mastodon.API.Favorites { } } + +extension Mastodon.API.Favorites { + + static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { + var actionString: String + switch favoriteKind { + case .create: + actionString = "/favourite" + case .destroy: + actionString = "/unfavourite" + } + let pathComponent = "statuses/" + statusID + actionString + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourite / Undo Favourite + /// + /// Add a status to your favourites list / Remove a status from your favourites list + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favorites( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + favoriteKind: FavoriteKind + ) -> AnyPublisher, Error> { + let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) + var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) + request.httpMethod = "POST" + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public enum FavoriteKind { + case create + case destroy + } + +} + +extension Mastodon.API.Favorites { + + static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { + let pathComponent = "statuses/" + statusID + "/favourited_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourited by + /// + /// View who favourited a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoriteBy( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index a74d0fcaa..9c39615f9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -18,6 +18,7 @@ extension Mastodon.Response { // application fields public let rateLimit: RateLimit? + public let link: Link? public let responseTime: Int? public var networkDate: Date { @@ -33,6 +34,11 @@ extension Mastodon.Response { }() self.rateLimit = RateLimit(response: response) + self.link = { + guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "link") else { return nil } + return Link(link: string) + }() + self.responseTime = { guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } return Int(string) @@ -43,6 +49,7 @@ extension Mastodon.Response { self.value = value self.date = old.date self.rateLimit = old.rateLimit + self.link = old.link self.responseTime = old.responseTime } @@ -90,3 +97,30 @@ extension Mastodon.Response { } } + +extension Mastodon.Response { + public struct Link { + public let maxID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + + init(link: String) { + self.maxID = { + guard let regex = try? NSRegularExpression(pattern: "max_id=([[:digit:]]+)", options: []) else { return nil } + let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..